注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

再说一遍!不要封装组件库!

最近公司里事儿比较多,项目也比较杂,但是因为公司的项目主要是聚焦OA方面,很多东西可以复用。 比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。 但是领导现在觉得还是维护一套组件库来的比较方便,一...
继续阅读 »

最近公司里事儿比较多,项目也比较杂,但是因为公司的项目主要是聚焦OA方面,很多东西可以复用。


比方说:表单、表格、搜索栏等等,这部分现阶段大部分都是各写各的,每个项目因为主要的开发不同,各自维护自己的一份。


image.png


但是领导现在觉得还是维护一套组件库来的比较方便,一来是减少重复工作量,提升开发效率,二来是方便新人加入团队以后尽量与老成员开发风格保持一致。


另外还有一个原因是项目内现在有的用AntDesign,有的用ElmentPlus,这些库的样式和UI设计出来的风格不搭,改起来也非常麻烦。


我听见这个提议以后后背冷汗都下来了。


我再跟大家强调一遍,不要封装组件库!


咱们说说为什么:


抬高开发成本


大部分人都感觉封装组件库是降低了开发成本,但实际上大部分项目并非如此,封装组件库大部分时候都是抬高了开发成本。


项目不同,面对的客户不同,需求也就不同,所以无论是客户方的需求还是UI设计稿都存在一定的差别,有些时候差距很大。


针对项目单独进行开发虽然在表面上看起来是浪费了人力资源,重复了很多工作,但是在后期开发和维护过程中会节省非常多的时间。


这部分都是成本。很多时候组件的开发并不是面对产品或者团队的,而是面向项目和客户的。


这也就导致了组件的开发会存在极大的不确定性,一方面是需求的不确定,另一方面是组件灵活度的不确定。


很多时候开发出来的组件库会衍生出N多个版本,切出N多个分支,最后在各个项目中引用,逐渐变成一个臃肿的垃圾代码集合体。


我不相信有人会在自己的项目上改完以后,还把修改的部分根据他人的反馈再进行调整,最后合并到 master 分支上去。


我从未见过有这个操作的兄弟。


技术达不到封装水平


团队内部技术不在一个水平线上,事实上也不可能在一个水平线上。


有些人的技术好,封装出来的组件确实很契合大多数的业务场景,有些人技术稍逊,封装出来的组件就不一定能契合项目。


但是如果你用他人封装的组件,牵扯到定制化需求的时候势必会改造,这时候改造就有可能会影响其他项目。


尤其一种情况,老项目升级,这是组件库最容易出问题的时候。可能上个版本封版的组件库在老项目运行的非常完美,但是需要升级的项目引用新的组件库的时候就会出现很多问题。


大部分程序员其实都达不到封装组件库的水准。


如果想要试一试可以参考ElmentUI老版本代码,自己封装一下Select、Input、Button这几个组件,看看和这些久经考验的开源组件库编码程序员还差多少。


技术负债严重


承接上一个问题,不是团队内每个人的水平都一样,并且每个人的编码风格也都是不一样的。(Ts最大的作用点)


可能组件库建立初期会节省非常多的重复工作,毕竟拿来就用,而且本身就是封装好的,简直可以为自己鼓掌了。


照着镜子问这是哪个天才编写的组件库,简直不要太棒了。


但是随着时间的推移,你会发现这个组件库越来越难用,需要考虑的方面越来越多,受影响的模块越来越多,你会变得越来越不敢动其中的代码。


项目越来越多,组件库中的分支和版本越来越多,团队中的人有些已经离开,有些人刚来,这时候技术负债就已经形成了。


更不要说大部分人没有写技术文档的习惯,甚至是连写注释的习惯都没有,功能全靠看代码和猜,技术上的负债越来越严重,这个阶段组件库离崩塌就已经不远了。


新项目在立项之初你就会本能的排斥使用组件库,但是对于老项目呢?改是不可能改动的,但是不维护Bug又挂在这儿。


那你到底是选择代码能跑,还是选择...


image.png


对个人发展不利


有些兄弟觉得能封装组件库,让自己的代码在这个团队,这个公司永远的流传下去,简直是跟青史留名差不多了。以后谁用到这个组件都会看到author后面写着我的名字。


但事实并非如此!


封装出的组件库大部分情况下会让你"青💩留名",因为后面的每个人用这个组件都会骂,这是哪个zz封装的组件,为啥这么写,这里不应该这么写嘛?


如果你一直呆在这个公司,由你一手搭建的这个组件库将伴随你在这个公司的整个职业生涯。


一时造轮子,一辈子维护轮子!


只要任何人用到你这个组件库,遇到了问题一定会来找你。不管你现在到底有没有在负责这个组件库!


这种通用性的组件库不可能没有问题,但是一旦有了问题找到你,你或者是解决不了,又或者是解决的不及时,都将或多或少的影响你的同事对你的评价。


当所有人都对你封装的这个组件库不满意,并且在开组会的时候提出来因为xx封装的组件库不好使,导致了项目延期,时间一长你的领导会对你有好印象?


结语


希望兄弟们还是要明白,对于一个职场人来说,挣钱最重要,能升上去最重要。其他的所有都是细枝末节,不必太在意。


对于客户和老板而言,能快速交付,把钱挣到手最重要,其他也都是无所谓的小事。


对于咱们自己来说,喜欢折腾是程序员的特质,但是要分清形势。


作者:李剑一
来源:juejin.cn/post/7532773597850206243
收起阅读 »

JavaScript 开发必备规范:命名、语法与代码结构指南

web
在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。 一、命名规范 变...
继续阅读 »

在 JavaScript 开发中,遵循良好的编程规范对于构建高效、可维护的代码至关重要。它不仅能提升代码的可读性,让团队成员之间更容易理解和协作,还能减少错误的发生,提高开发效率。本文将详细介绍 JavaScript 编程中的一些重要规范。



一、命名规范


变量和函数命名


采用小驼峰命名法,第一个单词首字母小写,后续单词首字母大写。例如firstName用于表示名字变量,getUserName函数用于获取用户名。这种命名方式能够清晰地区分变量和函数,并且让名称具有语义化,便于理解其用途。避免使用单字母或无意义的命名,如ab等,除非在特定的循环等场景下有约定俗成的用法。


常量命名


常量通常使用全大写字母,单词之间用下划线分隔,比如MAX_COUNT表示最大计数,API_URL表示 API 的链接地址。这样的命名方式能够直观地让开发者知道该变量是一个常量,其值在程序运行过程中不会改变。


二、语法规范


使用严格模式


在脚本或函数的开头添加'use strict';开启严格模式。严格模式下,JavaScript 会进行更严格的语法检查,比如禁止使用未声明的变量,防止意外创建全局变量等常见错误。它有助于开发者养成良好的编程习惯,提高代码的质量和稳定性。


// 严格模式
function strictWithExample() {
'use strict';
var obj = { x: 1 };
// 抛出 SyntaxError
with (obj) {
console.log(x);
}
}
strictWithExample();

语句结束加分号


尽管 JavaScript 在某些情况下可以省略分号,但为了避免潜在的错误和代码歧义,强烈建议在每条语句结束后都加上分号。


例如let num = 5let num = 5;,前者在一些复杂的代码结构中可能会因为自动分号插入机制而出现意想不到的问题,而后者则明确地表示了语句的结束。


let num = 5
console.log(num)
[1, 2, 3].forEach(function (element) {
console.log(element);
});

在上述代码中,let num = 5 后面没有分号,由于 [ 是 JavaScript 中的数组字面量符号,同时也可以用于数组的索引访问操作(例如 arr[0]),所以引擎会认为你可能想要对 num 进行某种与数组相关的操作,比如 num[1, 2, 3](虽然这在语法上是错误的,因为 num 是一个数字,不是数组)。


代码缩进


统一使用 2 个或 4 个空格进行缩进,这能让代码的层次结构一目了然。比如在嵌套的if - else语句、循环语句等结构中,合理的缩进能清晰地展示代码的逻辑关系,使代码更易于阅读和维护。


代码块使用大括号


即使代码块中只有一条语句,也建议使用大括号括起来。例如:


if (condition) {
doSomething();
}

这样在后续需要添加更多语句到代码块中时,能避免因遗漏大括号而导致的语法错误。


三、比较操作规范


尽量使用===!==进行比较操作,避免使用==!=。因为==!=在比较时会进行类型转换,这可能会带来意外结果。例如'5' == 5会返回true,而'5' === 5会返回false,在实际开发中,明确知道数据类型并使用全等操作符能减少错误的发生。


四、代码结构规范


避免全局变量污染


在 JavaScript 开发中,尤其是构建大型项目时,全局变量带来的问题不容小觑。全局变量如同在公共空间随意摆放的物品,极易引发混乱。在一个复杂项目中,可能有多个开发人员同时工作,不同模块的代码相互交织。如果每个模块都随意创建全局变量,很容易出现命名冲突。



  • 例如,一个模块定义了全局变量count用于记录某个操作的次数,另一个模块可能也需要使用count变量来记录其他信息,这就会导致变量值被意外覆盖,引发难以排查的错误。


同时,在大型项目中,代码的维护和调试本身就具有挑战性。全局变量的存在会使问题变得更加棘手。因为全局变量在整个程序的生命周期内都存在,其值可能在程序的任何地方被修改。当出现错误时,开发人员很难确定是哪个部分的代码对全局变量进行了不恰当的修改,增加了调试的难度和时间成本。


模块化


为了解决这些问题,模块化是一种非常有效的手段。通过将相关的功能代码封装在一个模块中,每个模块都有自己独立的作用域。在 JavaScript 中,ES6 引入了模块系统,使用exportimport关键字来管理模块的导出和导入。例如,有一个处理用户数据的模块userModule.js


// userModule.js
const userData = {
name: '',
age: 0
};

function setUserName(name) {
userData.name = name;
}

function getUserName() {
return userData.name;
}

export { setUserName, getUserName };

在这个模块中,userDatasetUserNamegetUserName函数都在模块内部作用域中,外部无法直接访问userData。只有通过导出的setUserNamegetUserName函数,其他模块才能间接操作userData。在其他模块中使用时,可以这样导入:


// main.js
import { setUserName, getUserName } from './userModule.js';

setUserName('John');
console.log(getUserName());

这样就有效地避免了全局变量的使用,降低了命名冲突的风险,同时也使得代码的结构更加清晰,易于维护和调试。


立即执行函数表达式(IIFE)


另一种方式是使用立即执行函数表达式(IIFE)。在 JavaScript 中,通过将函数定义包裹在括号中,并紧接着在后面加上括号进行调用,便形成了一个 IIFE。IIFE 能够创建一个独立的函数作用域,在该作用域内定义的变量和函数均为私有。这就确保了函数内部的变量和函数不会被外部随意访问和修改 。例如:


const app = (function () {
let privateVariable = 10;

function privateFunction() {
console.log('This is a private function.');
}

return {
publicFunction: function () {
privateFunction();
console.log('The value of private variable is:', privateVariable);
}
};
})();

app.publicFunction();

在上述代码中,



  • (function () {... })():在包裹匿名函数的括号后面再添加一对括号(),这对括号用于立即调用前面定义的匿名函数。当 JavaScript 引擎执行到这部分代码时,就会立即调用这个匿名函数,所以称为 “立即执行函数”。

  • privateVariableprivateFunction都在 IIFE 内部的私有作用域中,因此外部无法直接访问它们。通过返回一个包含publicFunction的对象,向外暴露了一个公共接口,这样一来既实现了功能,又避免了全局变量污染。


合理使用注释


在关键代码逻辑处添加注释,解释代码的功能、用途、算法思路等。注释要简洁准确,避免过度注释。



  • 例如在一个复杂的算法函数前,可以注释说明该算法的作用、输入参数和返回值的含义,方便其他开发者理解代码。

  • 但不要在过于简单的代码上添加冗余注释,如let num = 1; // 定义一个数字变量,这样的注释对理解代码没有实质性帮助。


五、注释规范


注释分为单行注释和多行注释。单行注释使用//,用于对某一行代码进行简单解释。多行注释使用/* */,适合对一段代码块进行详细说明。在写注释时,要确保注释与代码同步更新,避免代码修改后注释不再准确的情况。


六、异步编程规范


随着 JavaScript 在前端和后端开发中的广泛应用,异步编程变得越来越重要。使用async/await语法可以让异步代码看起来更像同步代码,提高代码的可读性。例如:


async function getData() {
try {
let response = await fetch('https://example.com/api');
let data = await response.json();
return data;
} catch (error) {
console.error('获取数据失败', error);
}
}

在处理多个异步操作时,要注意合理控制并发数量,避免因过多并发请求导致性能问题。


七、代码格式化规范


使用代码格式化工具,如 Prettier、ESLint 等,能够自动按照设定的规则对代码进行格式化。它可以统一代码风格,包括缩进、空格、换行等,使团队成员的代码风格保持一致,减少因风格差异带来的冲突和阅读障碍。


八、代码复用


尽量编写可复用的代码,通过函数封装、模块封装等方式,将重复使用的代码逻辑提取出来。



  • 例如,在多个地方需要对数据进行格式化处理,可以编写一个通用的数据格式化函数,在需要的地方调用,这样不仅能减少代码量,还方便维护和修改。


九、错误处理


在代码中要合理处理错误,使用try - catch块捕获可能出现的异常。对于异步操作,也要通过try - catch或者.catch方法来处理错误。



  • 例如在网络请求失败时,要及时向用户反馈错误信息,而不是让程序崩溃。同时,可以自定义错误类型,以便在不同的业务场景下进行更精准的错误处理。



遵循这些 JavaScript 编程规范,能够帮助开发者写出更整洁、高效、易于维护的代码。在实际开发中,团队可以根据项目需求进一步细化和完善这些规范,以提升整个项目的质量。



作者:逆袭的小黄鸭
来源:juejin.cn/post/7493346464920404003
收起阅读 »

我发现很多程序员都不会打日志。。

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊! 前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,...
继续阅读 »

大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊!


前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println() 打印一下吧。。。



要知道,日志是我们系统出现错误时,最快速有效的定位工具,没有日志给出的错误信息,遇到报错你就会一脸懵逼;而且日志还可以用来记录业务信息,比如记录用户执行的每个操作,不仅可以用于分析改进系统,同时在遇到非法操作时,也能很快找到凶手。


因此,对于程序员来说,日志记录是重要的基本功。但很多同学并没有系统学习过日志操作、缺乏经验,所以我写下这篇文章,分享自己在开发项目中记录日志的方法和最佳实践,希望对大家有帮助~


一、日志记录的方法


日志框架选型


有很多 Java 的日志框架和工具库,可以帮我们用一行代码快速完成日志记录。


在学习日志记录之前,很多同学应该是通过 System.out.println 输出信息来调试程序的,简单方便。


但是,System.out.println 存在很严重的问题!



首先,System.out.println 是一个同步方法,每次调用都会导致 I/O 操作,比较耗时,频繁使用甚至会严重影响应用程序的性能,所以不建议在生产环境使用。此外,它只能输出简单的信息到标准控制台,无法灵活设置日志级别、格式、输出位置等。


所以我们一般会选择专业的 Java 日志框架或工具库,比如经典的 Apache Log4j 和它的升级版 Log4j 2,还有 Spring Boot 默认集成的 Logback 库。不仅可以帮我们用一行代码更快地完成日志记录,还能灵活调整格式、设置日志级别、将日志写入到文件中、压缩日志等。


可能还有同学听说过 SLF4J(Simple Logging Facade for Java),看英文名就知道了,这玩意并不是一个具体的日志实现,而是为各种日志框架提供简单统一接口的日志门面(抽象层)。


啥是门面?


举个例子,现在我们要记录日志了,先联系到前台接待人员 SLF4J,它说必须要让我们选择日志的级别(debug / info / warn / error),然后要提供日志的内容。确认之后,SLF4J 自己不干活,屁颠屁颠儿地去找具体的日志实现框架,比如 Logback,然后由 Logback 进行日志写入。



这样做有什么好处呢?无论我们选择哪套日志框架、或者后期要切换日志框架,调用的方法始终是相同的,不用再去更改日志调用代码,比如将 log.info 改为 log.printInfo。


既然 SLF4J 只是玩抽象,那么 Log4j、Log4j 2 和 Logback 应该选择哪一个呢?



值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一个作者(俄罗斯程序员 Ceki Gülcü)。



首先,Log4j 已经停止维护,直接排除。Log4j 2 和 Logback 基本都能满足功能需求,那么就看性能、稳定性和易用性。



  • 从性能来说,Log4j 2 和 Logback 虽然都支持异步日志,但是 Log4j 基于 LMAX Disruptor 高性能异步处理库实现,性能更高。

  • 从稳定性来说,虽然这些日志库都被曝出过漏洞,但 Log4j 2 的漏洞更为致命,姑且算是 Logback 得一分。

  • 从易用性来说,二者差不多,但 Logback 是 SLF4J 的原生实现、Log4j2 需要额外使用 SLF4J 绑定器实现。


再加上 Spring Boot 默认集成了 Logback,如果没有特殊的性能需求,我会更推荐初学者选择 Logback,都不用引入额外的库了~


使用日志框架


日志框架的使用非常简单,一般需要先获取到 Logger 日志对象,然后调用 logger.xxx(比如 logger.info)就能输出日志了。


最传统的方法就是通过 LoggerFactory 手动获取 Logger,示例代码如下:


import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {
   private static final Logger logger = LoggerFactory.getLogger(MyService.class);

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

上述代码中,我们通过调用日志工厂并传入当前类,创建了一个 logger。但由于每个类的类名都不同,我们又经常复制这行代码到不同的类中,就很容易忘记修改类名。


所以我们可以使用 this.getClass 动态获取当前类的实例,来创建 Logger 对象:


public class MyService {
   private final Logger logger = LoggerFactory.getLogger(this.getClass());

   public void doSomething() {
       logger.info("执行了一些操作");
  }
}

给每个类都复制一遍这行代码,就能愉快地打日志了。


但我觉得这样做还是有点麻烦,我连复制粘贴都懒得做,怎么办?


还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j 注解,可以自动为当前类生成一个名为 log 的 SLF4J Logger 对象,简化了 Logger 的定义过程。示例代码如下:


import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyService {
   public void doSomething() {
       log.info("执行了一些操作");
  }
}

这也是我比较推荐的方式,效率杠杠的。



此外,你可以通过修改日志配置文件(比如 logback.xmllogback-spring.xml)来设置日志输出的格式、级别、输出路径等。日志配置文件比较复杂,不建议大家去记忆语法,随用随查即可。



二、日志记录的最佳实践


学习完日志记录的方法后,再分享一些我个人记录日志的经验。内容较多,大家可以先了解一下,实际开发中按需运用。


1、合理选择日志级别


日志级别的作用是标识日志的重要程度,常见的级别有:



  • TRACE:最细粒度的信息,通常只在开发过程中使用,用于跟踪程序的执行路径。

  • DEBUG:调试信息,记录程序运行时的内部状态和变量值。

  • INFO:一般信息,记录系统的关键运行状态和业务流程。

  • WARN:警告信息,表示可能存在潜在问题,但系统仍可继续运行。

  • ERROR:错误信息,表示出现了影响系统功能的问题,需要及时处理。

  • FATAL:致命错误,表示系统可能无法继续运行,需要立即关注。


其中,用的最多的当属 DEBUG、INFO、WARN 和 ERROR 了。


建议在开发环境使用低级别日志(比如 DEBUG),以获取详细的信息;生产环境使用高级别日志(比如 INFO 或 WARN),减少日志量,降低性能开销的同时,防止重要信息被无用日志淹没。


注意一点,日志级别未必是一成不变的,假如有一天你的程序出错了,但是看日志找不到任何有效信息,可能就需要降低下日志输出级别了。


2、正确记录日志信息


当要输出的日志内容中存在变量时,建议使用参数化日志,也就是在日志信息中使用占位符(比如 {}),由日志框架在运行时替换为实际参数值。


比如输出一行用户登录日志:


// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");

// 推荐
logger.debug("用户ID:{} 登录成功。", userId);

这样做不仅让日志清晰易读;而且在日志级别低于当前记录级别时,不会执行字符串拼接,从而避免了字符串拼接带来的性能开销、以及潜在的 NullPointerException 问题。所以建议在所有日志记录中,使用参数化的方式替代字符串拼接。


此外,在输出异常信息时,建议同时记录上下文信息、以及完整的异常堆栈信息,便于排查问题:


try {
   // 业务逻辑
catch (Exception e) {
logger.error("处理用户ID:{} 时发生异常:", userId, e);
}

3、控制日志输出量


过多的日志不仅会占用更多的磁盘空间,还会增加系统的 I/O 负担,影响系统性能。


因此,除了根据环境设置合适的日志级别外,还要尽量避免在循环中输出日志。


可以添加条件来控制,比如在批量处理时,每处理 1000 条数据时才记录一次:


if (index % 1000 == 0) {
   logger.info("已处理 {} 条记录", index);
}

或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:


StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
   try {
       processItem(item);
       logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
  } catch (Exception e) {
       logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
  }
}
logger.info(logBuilder.toString());

如果参数的计算开销较大,且当前日志级别不需要输出,应该在记录前进行级别检查,从而避免多余的参数计算:


if (logger.isDebugEnabled()) {
   logger.debug("复杂对象信息:{}"expensiveToComputeObject());
}

此外,还可以通过更改日志配置文件整体过滤掉特定级别的日志,来防止日志刷屏:


<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允许 INFO 级别及以上的日志通过 -->
   <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
       <level>INFO</level>
   </filter>
   <!-- 配置其他属性 -->
</appender>

4、把控时机和内容


很多开发者(尤其是线上经验不丰富的开发者)并没有养成记录日志的习惯,觉得记录日志不重要,等到出了问题无法排查的时候才追悔莫及。


一般情况下,需要在系统的关键流程和重要业务节点记录日志,比如用户登录、订单处理、支付等都是关键业务,建议多记录日志。


对于重要的方法,建议在入口和出口记录重要的参数和返回值,便于快速还原现场、复现问题。


对于调用链较长的操作,确保在每个环节都有日志,以便追踪到问题所在的环节。


如果你不想区分上面这些情况,我的建议是尽量在前期多记录一些日志,后面再慢慢移除掉不需要的日志。比如可以利用 AOP 切面编程在每个业务方法执行前输出执行信息:


@Aspect
@Component
public class LoggingAspect {

   @Before("execution(* com.example.service..*(..))")
   public void logBeforeMethod(JoinPoint joinPoint) {
       Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
       logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
  }
}

利用 AOP,还可以自动打印每个 Controller 接口的请求参数和返回值,这样就不会错过任何一次调用信息了。


不过这样做也有一个很重要的点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。



5、日志管理


随着日志文件的持续增长,会导致磁盘空间耗尽,影响系统正常运行,所以我们需要一些策略来对日志进行管理。


首先是设置日志的滚动策略,可以根据文件大小或日期,自动对日志文件进行切分。比如按文件大小滚动:


<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
   <maxFileSize>10MB</maxFileSize>
</rollingPolicy>

如果日志文件大小达到 10MB,Logback 会将当前日志文件重命名为 app.log.1 或其他命名模式(具体由文件名模式决定),然后创建新的 app.log 文件继续写入日志。


还有按照时间日期滚动:


<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>

上述配置表示每天创建一个新的日志文件,%d{yyyy-MM-dd} 表示按照日期命名日志文件,例如 app-2024-11-21.log


还可以通过 maxHistory 属性,限制保留的历史日志文件数量或天数:


<maxHistory>30</maxHistory>

这样一来,我们就可以按照天数查看指定的日志,单个日志文件也不会很大,提高了日志检索效率。


对于用户较多的企业级项目,日志的增长是飞快的,因此建议开启日志压缩功能,节省磁盘空间。


<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
   <fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

上述配置表示:每天生成一个新的日志文件,旧的日志文件会被压缩存储。


除了配置日志切分和压缩外,我们还需要定期审查日志,查看日志的有效性和空间占用情况,从日志中发现系统的问题、清理无用的日志信息等。


如果你想偷懒,也可以写个自动化清理脚本,定期清理过期的日志文件,释放磁盘空间。比如:


# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;

6、统一日志格式


统一的日志格式有助于日志的解析、搜索和分析,特别是在分布式系统中。


我举个例子大家就能感受到这么做的重要性了。


统一的日志格式:


2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒

这段日志整齐清晰,支持按照时间、线程、级别、类名和内容搜索。


不统一的日志格式:


2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功

emm,看到这种日志我直接原地爆炸!



建议每个项目都要明确约定和配置一套日志输出规范,确保日志中包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。


<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
   <encoder>
       <!-- 日志格式 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
   </encoder>
</appender>

也可以直接使用标准化格式,比如 JSON,确保所有日志遵循相同的结构,便于后续对日志进行分析处理:


<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
   <!-- 配置 JSON 编码器 -->
</encoder>

此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:


MDC.put("requestId""666");
MDC.put("userId""yupi");
logger.info("用户请求处理完成");
MDC.clear();

对应的日志配置如下:


<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
   <encoder>
       <!-- 包含 MDC 信息 -->
       <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
   </encoder>
</appender>

这样,每个请求、每个用户的操作一目了然。


7、使用异步日志


对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。


除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:


<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
   <queueSize>500</queueSize> <!-- 队列大小 -->
   <discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
   <neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
   <appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
   <appender-ref ref="FILE" />
</appender>

上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。


8、集成日志收集系统


在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。


但是搭建和运维 ELK 的成本还是比较大的,对于小团队,我的建议是不要急着搞这一套。




OK,就分享到这里,洋洋洒洒 4000 多字,希望这篇文章能帮助大家意识到日志记录的重要性,并养成良好的日志记录习惯。学会的话给鱼皮点个赞吧~


日志不是写给机器看的,是写给未来的你和你的队友看的!


更多


💻 编程学习交流:编程导航

📃 简历快速制作:老鱼简历

✏️ 面试刷题神器:面试鸭


作者:程序员鱼皮
来源:juejin.cn/post/7439785794917072896
收起阅读 »

前端常见的6种设计模式

web
一.为什么需要理解设计模式? 前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。 1.工厂模式 工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建...
继续阅读 »

一.为什么需要理解设计模式?


前端项目会随着需求迭代变得越来越复杂,设计模式的作用就是提前规避 “后期难改、牵一发动全身” 的坑,设计模式的核心价值:解决 “可维护、可扩展” 问题。


1.工厂模式


工厂模式:通过一个统一的 “工厂函数 / 类” 封装对象的创建逻辑,外界只需传入参数(如类型、配置),即可获取所需实例,无需关心实例内部的构造细节。核心是 “创建逻辑与使用逻辑分离”,实现批量、灵活地创建相似对象。


前端应用场景


1.Axios 实例


2.Vue实例


3.组件库中的 “表单组件工厂”,统一管理所有表单组件的基础属性(如 iddisabled


2.单例模式:确保全局只有一个实例


核心是为了解决 “重复创建实例导致的资源浪费、状态混乱、逻辑冲突” 问题—— 当某个对象在系统中只需要 “唯一存在” 时,单例模式能确保全局访问到的是同一个实例,从根源避免多实例带来的隐患。


前端典型场景:


1.Vuex单一store实例


2.浏览器的 window 对象


3.原型模式:通过 “复制” 创建新对象


原型模式的核心是 “基于已有对象(原型)复制创建新对象” —— 不是从零开始定义新对象的属性和方法,而是直接 “拷贝” 一个现有对象(原型)的结构,再根据需要修改差异化内容。


前端中原型模式的本质:依托 JavaScript 原型链。


JavaScript 本身就是基于原型的语言,所有对象都有 __proto__ 属性(指向其原型对象),这是原型模式在前端的 “天然实现”。


普通对象原型属性: 只有'proto'属性。


函数原型属性:proto、prototype属性。


prototype专属属性,只有函数有,用于 "当函数作为构造函数时,给新创建的实例提供原型"。


原型链顶端: Object.prototype.proto :指向null ;


前端典型场景:


1.Object.create()


2.Vue2 的数组方法重写:Vue2 为数组的pushpop等方法添加响应式逻辑,新数组会继承这些重写后的方法。


3.继承


工厂模式与原型模式区别:


工厂模式


基于参数 / 规则 “全新创建” 对象;


核心目的:封装复杂的创建逻辑,让调用者无需关心对象构造细节。


原型模式


基于 “已有原型对象” 复制生成新对象


核心目的:复用已有对象的属性 / 方法,减少重复定义,支持继承扩展


4.观察者模式:“一对多” 的依赖通知机制


观察者模式(Observer Pattern)是一种 “一对多” 的依赖关系设计模式:



  • 存在一个 “被观察者(Subject)” 和多个 “观察者(Observer)”;

  • 当被观察者的状态发生变化时,会自动通知所有依赖它的观察者,并触发观察者的更新逻辑;

  • 核心是 “解耦被观察者和观察者”—— 双方无需知道彼此的具体实现,只需通过统一的接口通信



前端典型场景:


1.浏览器事件监听(最基础的观察者模式)

浏览器的 DOM 事件本质是观察者模式的实现:



  • 被观察者:DOM 元素(如按钮);

  • 观察者:事件处理函数(onclickonchange 等);

  • 流程:给元素绑定事件(订阅)→ 元素状态变化(如被点击)→ 自动执行所有绑定的事件处理函数(通知观察者)。

  • 观察者模式的核心价值是 “状态变化自动同步


2.状态管理库(Vuex/Pinia/Redux)

Vuex、Redux 等全局状态管理库的核心机制就是观察者模式:



  • 被观察者:Store 中的状态(如 state.userstate.cart);

  • 观察者:依赖该状态的组件;

  • 流程:组件订阅状态(mapState 或 useSelector)→ 状态更新(commit 或 dispatch)→ 所有订阅该状态的组件自动重新渲染(收到通知更新)


3. 框架的响应式系统(Vue/React)

Vue 的响应式原理(数据驱动视图)和 React 的状态更新机制,底层都依赖观察者模式:



  • Vue:数据对象(data)是被观察者,视图(DOM)和计算属性是观察者 —— 数据变化时,Vue 自动触发依赖收集的观察者(视图重新渲染、计算属性重新计算)。

  • ReactsetState 触发状态更新时,组件树中依赖该状态的组件(观察者)会被重新渲染(收到通知执行更新)。


5.发布-订阅模式


发布 - 订阅模式是观察者模式的变体,核心是通过一个 “中间者(事件中心)” 实现 “发布者” 和 “订阅者” 的完全解耦 —— 发布者不用知道谁在订阅,订阅者也不用知道谁在发布,双方仅通过事件中心传递消息,就像 “报社(发布者)→ 邮局(事件中心)→ 订报人(订阅者)” 的关系。



  • 三大角色



    1. 发布者(Publisher) :负责 “发布事件”(比如触发某个状态变化,如用户登录、数据更新),但不直接联系订阅者;

    2. 订阅者(Subscriber) :负责 “订阅事件”(比如关注 “用户登录” 事件),并定义事件触发时的 “回调逻辑”(比如登录后显示欢迎信息);

    3. 事件中心(Event Bus) :中间枢纽,负责存储 “事件 - 订阅者” 的映射关系,接收发布者的事件并通知所有订阅者。



  • 核心逻辑:订阅者先在事件中心 “订阅” 某个事件 → 发布者在事件中心 “发布” 该事件 → 事件中心找到所有订阅该事件的订阅者,触发它们的回调。


与观察者模式区别:


维度观察者模式发布 - 订阅模式
依赖关系被观察者直接持有观察者列表发布者和订阅者无直接依赖,靠事件中心连接
耦合程度较高(被观察者知道有哪些观察者)极低(双方不知道彼此存在)
适用场景单一被观察者、观察者明确的场景跨模块、多发布者 / 多订阅者的复杂场景
典型例子Vue 响应式(data 直接通知依赖的 DOM)跨组件通信(事件总线)、全局状态更新

前端典型场景:


1.跨组件通信(事件总线 Event Bus)


2.全局状态管理(如 Redux 的 Action 机制)



  • 发布者:组件通过 dispatch(action) 发布 “状态变更事件”;

  • 事件中心:Redux 的 Store,存储状态并管理订阅者;

  • 订阅者:组件通过 store.subscribe(() => { ... }) 订阅状态变化,状态更新时重新渲染。



状态管理库到底是观察者模式还是发布 - 订阅模式?


状态管理库(如 Vuex、Redux)之所以会让人觉得 “既是观察者模式,又是发布 - 订阅模式”,是因为它们融合了两种模式的核心思想—— 在底层实现上,既保留了观察者模式 “状态与依赖直接关联” 的特性,又通过 “中间层” 实现了发布 - 订阅模式的 “解耦” 优势,本质是两种模式的结合与优化


1. 底层:状态与组件的 “观察者模式”(直接依赖)


状态管理库中, “全局状态” 与 “依赖该状态的组件”  之间是典型的观察者模式:



  • 被观察者:全局状态(如 Vuex 的 state、Redux 的 store);

  • 观察者:订阅了该状态的组件;

  • 逻辑:当状态发生变化时,会直接通知所有依赖它的组件(观察者),触发组件重新渲染。


这一层的核心是 “精准依赖”—— 组件只订阅自己需要的状态(比如 Vue 的 mapState、Redux 的 useSelector),状态变化时只有相关组件会被通知,避免无效更新。


2. 上层:组件与状态的 “发布 - 订阅模式”(解耦通信)


状态管理库中, “组件触发状态变更” 与 “状态变更通知组件”  的过程,通过 “中间层(如 commit/dispatch)” 实现,类似发布 - 订阅模式:



  • 发布者:触发状态变更的组件(通过 store.commit('increment') 或 dispatch(action) 发布 “状态变更事件”);

  • 事件中心:状态管理库的核心逻辑(如 Vuex 的 Store 实例、Redux 的 dispatch 机制);

  • 订阅者:依赖状态的组件(通过 subscribe 或计算属性订阅状态)。


这一层的核心是 “解耦”—— 组件不需要知道谁会处理状态变更,也不需要知道哪些组件依赖该状态;状态管理库作为中间层,接收 “发布” 的变更请求,处理后再 “通知” 订阅者,双方完全隔离。


6.代理模式


代理模式(Proxy Pattern)是一种 “通过中间代理对象控制对原始对象的访问” 的设计模式 —— 不直接操作目标对象,而是通过一个 “代理” 来间接访问,代理可以在访问前后添加额外逻辑(如权限校验、缓存、日志记录等)。


核心作用:“控制访问” 与 “增强功能”

前端典型场景:


1. 权限控制代理(限制访问)

2.Vue3响应式核心

用 “中间商” 的思路理解 Vue3 响应式:


  • 目标对象:你定义的 data 数据(如 { count: 0, user: { name: '张三' } });

  • 代理对象:Vue3 通过 reactive() 或 ref() 创建的 “响应式代理”(本质是 Proxy 实例);

  • 调用者:组件中的模板(视图)或业务逻辑(如 {{ count }} 或 count.value++);

  • 代理的 “附加操作” :拦截数据的读取(get)和修改(set),在读取时 “收集依赖”(记录哪些地方用到了这个数据),在修改时 “触发更新”(通知依赖的地方重新渲染)。


1. 目标对象:原始数据 const target = { count: 0 }; 
2. 依赖收集的容器:记录哪些函数依赖了数据(比如视图渲染函数)
const deps = new Set();
3. 创建代理对象(核心:拦截读写,添加响应式逻辑)
const reactiveProxy = new Proxy(target,
{
// 拦截“读取数据”操作(如访问 count 时)
get(target, key){
// 附加操作1:
收集依赖(假设当前正在执行的函数是依赖)
if (currentEffect) { deps.add(currentEffect); // 把依赖存起来 }
return target[key]; // 返回原始值 },
}
// 拦截“修改数据”操作(如 count++ 时)
set(target, key, value) {
// 更新原始数据
target[key] = value;
// 附加操作2:触发更新(通知所有依赖重新执行)
deps.forEach(effect => effect()); return true; } });
}

扩展:Vue3响应式对比vue2响应式

1.Vue2 用的是 Object.defineProperty 拦截属性,只能拦截已存在的属性(对新增属性、数组索引修改不友好);


具体原因拆解:

Object.defineProperty 的工作方式是给对象的某个具体属性添加 getter/setter


但数组本质是特殊对象(属性是索引,如 arr[0]arr[1]),如果用 Object.defineProperty 拦截数组,只能逐个拦截索引(如 01),但存在两个致命问题:


1.问题一:无法拦截数组的原生方法(push/pop/splice 等)
数组的常用操作(如 push 新增元素、splice 删除元素)是通过调用数组原型上的方法实现的,这些方法会直接修改数组本身,但 Object.defineProperty 无法拦截 “方法调用”,只能拦截 “属性读写”。所以最终Vue2采取了这7个数组方法的重写。


 arrayMethods[method] = function(...args) {
// 先调用原生方法(比如 push 实际添加元素)
const result = arrayProto[method].apply(this, args);
// 手动触发更新(通知依赖重新渲染)
notifyUpdate();
return result;

2.问题二:拦截数组索引的成本极高,且不实用。



  • 初始化成本高:数组长度可能很大(甚至动态变化),提前拦截所有索引会浪费性能;

  • 数组长度变化无法拦截
    当 arr.length = 0 时,数组会清空所有元素(即删除索引 012),但 Object.defineProperty 只能知道 length 被改成了 0无法知道具体哪些元素被删除了


对于响应式系统来说,需要知道 “哪些元素变化了” 才能精准通知依赖这些元素的视图。但 length 拦截只能知道 “长度变了”,无法定位具体变化的元素,导致依赖这些元素的视图可能不会更新(比如某个视图依赖 arr[0]length=0 后 arr[0] 不存在了,但视图可能还显示旧值)。


2.Vue3 用 Proxy 直接代理整个对象,能拦截所有属性的读写(包括新增、删除、数组操作),是更彻底、更灵活的代理模式实现,这也是 Vue3 响应式比 Vue2 强大的核心原因之一。


总结


最后想强调:设计模式不是必须遵守的 “规则”,而是解决问题的 “工具”。在实际开发中,我们不需要刻意追求 “用满所有模式”,而是根据场景选择合适的工具:



  • 需批量创建对象 → 工厂模式

  • 需全局唯一实例 → 单例模式

  • .....


参考文章:juejin.cn/post/754253…


作者:大杯咖啡
来源:juejin.cn/post/7563981206674817051
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

微服务正在悄然消亡:这是一件美好的事

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同...
继续阅读 »

最近在做的事情正好需要系统地研究微服务与单体架构的取舍与演进。读到这篇文章《Microservices Are Quietly Dying — And It’s Beautiful》,许多观点直击痛点、非常启发,于是我顺手把它翻译出来,分享给大家,也希望能给同样在复杂性与效率之间权衡的团队一些参考。


微服务正在悄然消亡:这是一件美好的事


为了把我们的创业产品扩展到数百万用户,我们搭建了 47 个微服务。


用户从未达到一百万,但我们达到了每月 23,000 美元的 AWS 账单、长达 14 小时的故障,以及一个再也无法高效交付新功能的团队。


那一刻我才意识到:我们并没有在构建产品,而是在搭建一座分布式的自恋纪念碑。


image.png


我们都信过的谎言


五年前,微服务几乎是教条。Netflix 用它,Uber 用它。每一场技术大会、每一篇 Medium 文章、每一位资深架构师都在高喊同一句话:单体不具备可扩展性,微服务才是答案。


于是我们照做了。我们把 Rails 单体拆成一个个服务:用户服务、认证服务、支付服务、通知服务、分析服务、邮件服务;然后是子服务,再然后是调用服务的服务,层层套叠。


到第六个月,我们已经在 12 个 GitHub 仓库里维护 47 个服务。我们的部署流水线像一张地铁图,架构图需要 4K 显示器才能看清。


当“最佳实践”变成“最差实践”


我们不断告诫自己:一切都在运转。我们有 Kubernetes,有服务网格,有用 Jaeger 的分布式追踪,有 ELK 的日志——我们很“现代”。


但那些光鲜的微服务文章从不提的一点是:分布式的隐性税


每一个新功能都变成跨团队的协商。想给用户资料加一个字段?那意味着要改五个服务、提三个 PR、协调两周,并进行一次像劫案电影一样精心编排的数据库迁移。


我们的预发布环境成本甚至高于生产环境,因为想测试任何东西,都需要把一切都跑起来。47 个服务在 Docker Compose 里同时启动,内存被疯狂吞噬。


那个彻夜崩溃的夜晚


凌晨 2:47,Slack 被消息炸翻。


生产环境宕了。不是某一个服务——是所有服务。支付服务连不上用户服务,通知服务不断超时,API 网关对每个请求都返回 503。


我打开分布式追踪面板:一万五千个 span,全线飘红。瀑布图像抽象艺术。我花了 40 分钟才定位出故障起点。


结果呢?一位初级开发在认证服务上发布了一个配置变更,只是一个环境变量。它让令牌校验多了 2 秒延迟,这个延迟在 11 个下游服务间层层传递,超时叠加、断路器触发、重试逻辑制造请求风暴,整个系统在自身重量下轰然倒塌。


我们搭了一座纸牌屋,却称之为“容错架构”。


我们花了六个小时才修复。并不是因为 bug 复杂——它只是一个配置的单行改动,而是因为排查分布式系统就像破获一桩谋杀案:每个目击者说着不同的语言,而且有一半在撒谎。


那个被忽略的低语


一周后,在复盘会上,我们的 CTO 说了句让所有人不自在的话:


“要不我们……回去?”


回到单体。回到一个仓库。回到简单。


会议室一片沉默。你能感到认知失调。我们是工程师,我们很“高级”。单体是给传统公司和训练营毕业生用的,不是给一家正打造未来的 A 轮初创公司用的。


但随后有人把指标展开:平均恢复时间 4.2 小时;部署频率每周 2.3 次(从单体时代的每周 12 次一路下滑);云成本增长速度比营收快 40%。


数字不会说谎。是架构在拖垮我们。


美丽的回归


我们用了三个月做整合。47 个服务归并成一个模块划分清晰的 Rails 应用;Kubernetes 变成负载均衡后面的三台 EC2;12 个仓库的工作流收敛成一个边界明确的仓库。


结果简直让人尴尬。


部署时间从 25 分钟降到 90 秒;AWS 账单从 23,000 美元降到 3,800 美元;P95 延迟提升了 60%,因为我们消除了 80% 的网络调用。更重要的是——我们又开始按时交付功能了。


开发者不再说“我需要和三个团队协调”,而是开始说“午饭前给你”。


我们的“分布式系统”变回了结构良好的应用。边界上下文变成 Rails 引擎,服务调用变成方法调用,Kafka 变成后台任务,“编排层”……就是 Rails 控制器。


它更快,它更省,它更好。


我们真正学到的是什么


这是真相:我们为此付出两年时间和 40 万美元才领悟——


微服务不是一种纯粹的架构模式,而是一种组织模式。Netflix 需要它,因为他们有 200 个团队。你没有。Uber 需要它,因为他们一天发布 4,000 次。你没有。


复杂性之所以诱人,是因为它看起来像进步。 拥有 47 个服务、Kubernetes、服务网格和分布式追踪,看起来很“专业”;而一个单体加一套 Postgres,看起来很“业余”。


但复杂性是一种税。它以认知负担、运营开销、开发者幸福感和交付速度为代价。


而大多数初创公司根本付不起这笔税。


我们花了两年时间为并不存在的规模做优化,同时牺牲了能让我们真正达到规模的简单性。


你不需要 50 个微服务,你需要的是自律


软件架构的“肮脏秘密”是:好的设计在任何规模都奏效。


一个结构良好的单体,拥有清晰的模块、明确的边界上下文和合理的关注点分离,比一团由希望和 YAML 勉强粘合在一起的微服务乱麻走得更远。


微服务并不是因为“糟糕”而式微,而是因为我们出于错误的理由使用了它。我们选择了分布式的复杂性而不是本地的自律,选择了运营的负担而不是价值的交付。


那些悄悄回归单体的公司并非承认失败,而是在承认更难的事实:我们一直在解决错误的问题。


所以我想问一个问题:你构建微服务,是在逃避什么?


如果答案是“一个凌乱的代码库”,那我有个坏消息——分布式系统不会修好坏代码,它只会让问题更难被发现。


作者:程序猿DD
来源:juejin.cn/post/7563860666349649970
收起阅读 »

electron-updater实现热更新完整流程

web
最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。 一、配置electron-builder 在electron-builder的配置文件"build"中增加 "publish": [ ...
继续阅读 »

最近项目做了一个electron项目,记录一下本次客户端热更新中对electron-updater的使用以及遇到的一些问题。


一、配置electron-builder


在electron-builder的配置文件"build"中增加


"publish": [
{
"provider": "generic",
"url": "oss://xxx",
}
]

url: 打包出来的文件存放的地址,配置之后会生成latest.yml文件。electron-updater会去比较这个文件,判断是否需要更新。


二、electron-updater的使用


官方文档: http://www.electron.build/auto-update…


主进程


import { autoUpdater } from "electron-updater";
const { ipcMain } = require("electron");

// 配置提供更新的程序,及build中配置的url
autoUpdater.setFeedURL("oss://xxx")
// 是否自动更新,如果为true,当可以更新时(update-available)自动执行更新下载。
autoUpdater.autoDownload = false

// 1. 在渲染进程里触发获取更新,开始进行更新流程。 (根据具体需求)
ipcMain.on("checkForUpdates", (e, arg) => {
autoUpdater.checkForUpdates();
});

autoUpdater.on("error", function (error) {
printUpdaterMessage('error');
mainWindow.webContents.send("updateError", error);
});

// 2. 开始检查是否有更新
autoUpdater.on("checking-for-update", function () {
printUpdaterMessage('checking');
});

// 3. 有更新时触发
autoUpdater.on("update-available", function (info) {
printUpdaterMessage('updateAvailable');
// 4. 告诉渲染进程有更新,info包含新版本信息
mainWindow.webContents.send("updateAvailable", info);
});

// 7. 收到确认更新提示,执行下载
ipcMain.on('comfirmUpdate', () => {
autoUpdater.downloadUpdate()
})

autoUpdater.on("update-not-available", function (info) {
printUpdaterMessage('updateNotAvailable');
});

// 8. 下载进度,包含进度百分比、下载速度、已下载字节、总字节等
// ps: 调试时,想重复更新,会因为缓存导致该事件不执行,下载直接完成,可找到C:\Users\40551\AppData\Local\xxx-updater\pending下的缓存文件将其删除(这是我本地的路径)
autoUpdater.on("download-progress", function (progressObj) {
printUpdaterMessage('downloadProgress');
mainWindow.webContents.send("downloadProgress", progressObj);
});

// 10. 下载完成,告诉渲染进程,是否立即执行更新安装操作
autoUpdater.on("update-downloaded", function () {
mainWindow.webContents.send("updateDownloaded");
// 12. 立即更新安装
ipcMain.on("updateNow", (e, arg) => {
autoUpdater.quitAndInstall();
});
}
);

// 将日志在渲染进程里面打印出来
function printUpdaterMessage(arg) {
let message = {
error: "更新出错",
checking: "正在检查更新",
updateAvailable: "检测到新版本",
downloadProgress: "下载中",
updateNotAvailable: "无新版本",
};
mainWindow.webContents.send("printUpdaterMessage", message[arg]??arg);
}


渲染进程:


// 5. 收到主进程可更新的消息,做自己的业务逻辑
ipcRenderer.on('updateAvailable', (event, data) => {
// do sth.
})

// 6. 点击确认更新
ipcRenderer.send('comfirmUpdate')

// 9. 收到进度信息,做进度条
ipcRenderer.on('downloadProgress', (event, data) => {
// do sth.
})

// 11. 下载完成,反馈给用户是否立即更新
ipcRenderer.on('updateDownloaded', (event, data) => {
// do sth.
})

// 12. 告诉主进程,立即更新
ipcRenderer.send("updateNow");

本地环境


如果想在本地环境调试更新,会报错找不到dev-app-update.yml文件
需要自己在根目录(或报错时显示的目录下)手动新建一个dev-app-update.yml里就可以了。文件,将打包生成好的latest.yml复制到dev-app-update.yml里就可以了。


完成截图


image.png


image.png


作者:致命一击
来源:juejin.cn/post/7054811432714108936
收起阅读 »

Spring Boot 分布式事务高阶玩法:从入门到精通

嘿,各位 Java 小伙伴们!今天咱们要来聊聊 Spring Boot 里一个超酷炫但又有点让人头疼的家伙 —— 分布式事务。这玩意儿就像是一场大型派对的组织者,要确保派对上所有的活动(操作)要么都顺顺利利地进行,要么就一起取消,绝对不能出现有的活动进行了一半...
继续阅读 »

嘿,各位 Java 小伙伴们!今天咱们要来聊聊 Spring Boot 里一个超酷炫但又有点让人头疼的家伙 —— 分布式事务。这玩意儿就像是一场大型派对的组织者,要确保派对上所有的活动(操作)要么都顺顺利利地进行,要么就一起取消,绝对不能出现有的活动进行了一半,有的却没开始的尴尬局面。


为啥要有分布式事务


在以前那种单体应用的小世界里,事务处理就像在自己家里整理东西,所有的东西(数据)都在一个地方,要保证操作的一致性很容易。但随着业务越来越复杂,应用变成了分布式的 “大杂烩”,各个服务就像住在不同房子里的小伙伴,这时候再想保证所有操作都一致,就需要分布式事务这个 “超级协调员” 出场啦。


Spring Boot 里的分布式事务支持


Spring Boot 对分布式事务的支持就像是给你配备了一套超级工具包。其中,@Transactional注解大家肯定都很熟悉,在单体应用里它就是事务管理的小能手。但在分布式场景下,我们还有更厉害的武器,比如基于 XA 协议的分布式事务管理器,以及像 Seata 这样的开源框架。


XA 协议的分布式事务管理器


XA 协议就像是一个国际通用的 “交流规则”,它规定了数据库和事务管理器之间怎么沟通。在 Spring Boot 里使用 XA 协议的分布式事务管理器,就像是给各个服务的数据库都请了一个翻译,让它们能准确地交流事务相关的信息。


下面我们来看一段简单的代码示例,假设我们有两个服务,一个是订单服务,一个是库存服务,我们要在创建订单的同时扣减库存,并且保证这两个操作要么都成功,要么都失败。


首先,我们需要配置 XA 数据源,这里以 MySQL 为例:


@Configuration
public class XADataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
@Bean
public DataSource dataSource() {
return dataSourceProperties().initializeDataSourceBuilder()
.type(com.mysql.cj.jdbc.MysqlXADataSource.class)
.build();
}
}

然后,配置事务管理器:


@Configuration
public class XATransactionConfig {
@Autowired
private DataSource dataSource;
@Bean
public PlatformTransactionManager transactionManager() throws SQLException {
return new JtaTransactionManager(new UserTransactionFactory(), new TransactionManagerFactory(dataSource));
}
}

接下来,在业务代码里使用@Transactional注解:


@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}

在这个例子里,createOrder方法上的@Transactional注解就像一个 “指挥官”,它会协调订单保存和库存扣减这两个操作,确保它们在同一个事务里执行。


Seata 框架


Seata 就像是一个更智能、更强大的 “事务指挥官”。它有三个重要的组件:TC(Transaction Coordinator)事务协调器、TM(Transaction Manager)事务管理器和 RM(Resource Manager)资源管理器。TC 就像一个调度中心,TM 负责发起和管理事务,RM 则负责管理资源和提交 / 回滚事务。


使用 Seata,我们首先要在项目里引入相关依赖:


<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

然后,配置 Seata 客户端:


seata:
application-id: ${spring.application.name}
tx-service-group: my_test_tx_group
enable-auto-data-source-proxy: true
client:
rm:
async-commit-buffer-limit: 10000
lock:
retry-interval: 10
retry-times: 30
retry-policy-branch-rollback-on-conflict: true
tm:
commit-retry-count: 5
rollback-retry-count: 5
undo:
data-validation: true
log-serialization: jackson
log-table: undo_log

在业务代码里,我们使用@GlobalTransactional注解来开启全局事务:


@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StockService stockService;
@GlobalTransactional
public void createOrder(Order order) {
orderRepository.save(order);
stockService.decreaseStock(order.getProductId(), order.getQuantity());
}
}

这里的@GlobalTransactional注解就像是给整个分布式事务场景下了一道 “圣旨”,让所有涉及到的服务都按照统一的事务规则来执行。


总结


分布式事务虽然复杂,但有了 Spring Boot 提供的强大支持,以及像 Seata 这样优秀的框架,我们也能轻松应对。就像掌握了一门高超的魔法,让我们的分布式系统变得更加可靠和强大。希望今天的分享能让大家对 Spring Boot 中的分布式事务有更深入的理解,在开发的道路上一路 “开挂”,解决各种复杂的业务场景。


作者:装睡鹿先生
来源:juejin.cn/post/7490588889948061750
收起阅读 »

Android实战-Native层thread的实现方案

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下: Android的Native层thread的实现方案一般有两种: Linux上的po...
继续阅读 »

最近阅读Android源码,很多地方都涉及到了线程的概念,比如BootAnimation,应用层的线程倒是略懂一二,Framework的线程却是知之甚少,因此特此记录如下:


Android的Native层thread的实现方案一般有两种:



  • Linux上的posix线程方案

  • Native层面封装的Thread类(用的最多)


posix线程方案


首先创建空文件夹项目-Linux_Thread


其次在Linux_Thread文件夹下创建一个文件thread_posix.c:主要逻辑是在主线程中创建一个子线程mythread,主线程等待子线程执行完毕后,退出进程:


#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <utils/Log.h>

//线程执行逻辑
void *thread_posix_function(void *arg)
{
(void*) arg;
int i;
for(i = 0; i < 40; i++)
{
printf("hello thread i + %d\n", i);
ALOGD("hello thread i + %d\n", i);
sleep(1);
}
return NULL;
}

int main(void)
{

pthread_t mythread;
//创建线程并执行
if(pthread_create(&mythread, NULL, thread_posix_function, NULL))
{
ALOGD("error createing thread");
abort();
}

sleep(1);
//等待mythread线程执行结束
if(pthread_join(mythread, NULL))
{
ALOGD("error joining thread");
abort();
}
ALOGD("hello thread has run end exit\n");
//退出
exit(0);
}

然后在Linux_Thread文件夹下创建项目构建文件Android.mk中将文件编译为可执行文件:


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := linux_thread
LOCAL_SHARED_LIBRARIES := liblog
LOCAL_SRC_FILES := thread_posix.c
LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

最后确认Linux_Thread项目位于aosp源码文件夹内,开始编译Linux_Thread项目:


source build/envsetup.sh
lunch
make linux_thread

执行成功后,找到输出的可执行文件linux_thread,将文件push到Android设备中去:


adb push linux_thread /data/local/tmp/

注意如果出现报错 Permission denied,需要对文件进行权限修改:


chmod -R 777 linux_thread

开始启动linux_thread:


./linux_thread

image.png


同时也可以通过日志打印输出:


 adb shell locat | grep hello

屏幕截图 2025-05-06 173436.png


以上就是posix线程方案的实现。


Native层的Thread类方案


源码分析


Native层即Framework层的C++部分,Thread的相关代码位置



头文件:system/core/libutils/include/utils/Thread.h


源文件:system/core/libutils/Threads.cpp



# system/core/libutils/include/utils/Thread.h
class Thread : virtual public RefBase
{
public:
explicit Thread(bool canCallJava = true);
virtual ~Thread();

virtual status_t run( const char* name,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0)
;

virtual void requestExit();

virtual status_t readyToRun();

status_t requestExitAndWait();

status_t join();

bool isRunning() const;

...
}

Thread继承于RefBase,在构造函数中对canCallJava变量默认赋值为ture,同时声明了一些方法,这些方法都在源文件中实现。


status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
...
if (mRunning) {
// thread already started
return INVALID_OPERATION;
}
...
mRunning = true;

bool res;
if (mCanCallJava) {
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}

if (res == false) {
mStatus = UNKNOWN_ERROR; // something happened!
mRunning = false;
mThread = thread_id_t(-1);
mHoldSelf.clear(); // "this" may have gone away after this.

return UNKNOWN_ERROR;
}
return OK;

// Exiting scope of mLock is a memory barrier and allows new thread to run
}

int androidCreateRawThreadEtc(android_thread_func_t entryFunction,
void *userData,
const char* threadName __android_unused,
int32_t threadPriority,
size_t threadStackSize,
android_thread_id_t *threadId)

{
...
errno = 0;
pthread_t thread;
int result = pthread_create(&thread, &attr,
(android_pthread_entry)entryFunction, userData);
pthread_attr_destroy(&attr);
if (result != 0) {
ALOGE("androidCreateRawThreadEtc failed (entry=%p, res=%d, %s)\n"
"(android threadPriority=%d)",
entryFunction, result, strerror(errno), threadPriority);
return 0;
}
...
return 1;
}

int Thread::_threadLoop(void* user)
{
...
bool first = true;

do {
bool result;
//是否第一次执行
if (first) {
first = false;
self->mStatus = self->readyToRun();
result = (self->mStatus == OK);

if (result && !self->exitPending()) {
result = self->threadLoop();
}
} else {
result = self->threadLoop();
}

// establish a scope for mLock
{
Mutex::Autolock _l(self->mLock);
//根据结果来跳出循环
if (result == false || self->mExitPending) {
self->mExitPending = true;
self->mRunning = false;
self->mThread = thread_id_t(-1);
self->mThreadExitedCondition.broadcast();
break;
}
}
strong.clear();
strong = weak.promote();
} while(strong != nullptr);

return 0;
}

在run方法中,mCanCallJava变量一般为false,是在thread创建的时候赋值的。从而进入androidCreateRawThreadEtc()方法创建线程,在此函数中,可以看见还是通过pthread_create()方法创建Linux的posix线程(可以理解为Native的Thread就是对posiz的一个封装);线程运行时回调的函数参数entryFunction值为_threadLoop函数,因此创建的线程会回调到_threadLoop函数中去;_threadLoop函数里则是一个循环逻辑,线程第一次魂环会调用readyToRun()函数,然后再调用threadLoop函数执行线程逻辑,后面就会根据threadLoop执行的结果来判断是否再继续执行下去。


代码练习


头文件MyThread.h


#ifndef _MYTHREAD_H
#define _MYTHREAD_H

#include <utils/threads.h>

namespace android
{
class MyThread: public Thread
{
public:
MyThread();
//创建线程对象就会被调用
virtual void onFirstRef();
//线程创建第一次运行时会被调用
virtual status_t readyToRun();
//根据返回值是否继续执行
virtual bool threadLoop();
virtual void requestExit();

private:
int hasRunCount = 0;
};
}
#endif

源文件MyThread.cpp


#define LOG_TAG "MyThread"

#include <utils/Log.h>
#include "MyThread.h"

namespace android
{
//通过构造函数对mCanCallJava赋值为false
MyThread::MyThread(): Thread(false)
{
ALOGD("MyThread");
}

bool MyThread::threadLoop()
{
ALOGD("threadLoop hasRunCount = %d", hasRunCount);
hasRunCount++;
//计算10次后返回false,表示逻辑结束,线程不需要再继续执行咯
if(hasRunCount == 10)
{
return false;
}
return true;
}

void MyThread::onFirstRef()
{
ALOGD("onFirstRef");
}

status_t MyThread::readyToRun()
{
ALOGD("readyToRun");
return 0;
}

void MyThread::requestExit()
{
ALOGD("requestExit");
}
}

程序入口Main.cpp


#define LOG_TAG "Main"

#include <utils/Log.h>
#include <utils/threads.h>
#include "MyThread.h"

using namespace android;

int main()
{

sp<MyThread> thread = new MyThread;
thread->run("MyThread", PRIORITY_URGENT_DISPLAY);
while(1)
{
if(!thread->isRunning())
{
ALOGD("main thread -> isRunning == false");
break;
}
}

ALOGD("main end");
return 0;
}

项目构建文件Android.mk


LOCAL_PATH:=${call my-dir}
include ${CLEAR_VARS}
LOCAL_MODULE := android_thread
LOCAL_SHARED_LIBRARIES := libandroid_runtime \
libcutils \
libutils \
liblog
LOCAL_SRC_FILES := MyThread.cpp \
Main.cpp \

LOCAL_PRELINK_MODULE := false
include ${BUILD_EXECUTABLE}

项目目录如下:
image.png


通过命令带包得到android_thread可执行文件放入模拟器运行:


屏幕截图 2025-05-08 140036.png


作者:抛空
来源:juejin.cn/post/7501624826286669859
收起阅读 »

聊聊SliverPersistentHeader优先消费滑动的设计

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。 ...
继续阅读 »

Flutter中想在滚动体系中实现复杂的效果,肯定逃不开SIlver全家桶,Sliver中提供了很多好用的组件可以实现各种滚动效果,而如果需要实现,QQ好友分组的那种吸顶效果,Flutter可以可以使用SliverPersistentHeader轻松的做到。


带动画的吸顶滑动


那如果需求再复杂一点,吸顶组件滑动的同时还希望增加吸附的动画效果,其实SliverPersistentHeader也可以很轻松的实现。


吸附效果.gif


但是实现这个效果有一个特殊的“Feature”,他会优先消费滑动事件,导致底部滑动没有在我们预期的时机传递给上一级消费。


提前消费.gif


最近项目中处理这个问题时也没搜到相关的文章,所以今天想来聊聊这个组件的优先消费设计,以及很简单的一个定制效果。


在 Flutter 中,SliverPersistentHeader是实现“滚动时动态变化且可持久化”头部的核心组件,其浮动模式(floating: true)的动画交互(如滚动停止自动吸附、反向滚动立即展开)是通过多组件协同实现的。


瞅瞅源码


SliverPersistentHeader


那就从SliverPersistentHeader开始,让我们看看是如何实现动画的吸顶效果


class SliverPersistentHeader extends StatelessWidget {
  const SliverPersistentHeader({
    
super.key,
    required 
this.delegate,
    
this.pinned = false,
    
this.floating = false,
  }
)
;

  final bool floating;

  @override
  Widget build(BuildContext context)
 {
    if (floating && pinned) {
      return _SliverFloatingPinnedPersistentHeader(delegatedelegate);
    }
    if (pinned) {
      return _SliverPinnedPersistentHeader(delegatedelegate);
    }
    if (floating) {
      return _SliverFloatingPersistentHeader(delegatedelegate);
    }
    return _SliverScrollingPersistentHeader(delegatedelegate);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties)
 {
    super.debugFillProperties(properties);
    ....
  }
}

首先这个组件并不直接实现渲染逻辑,而是根据我们传入的flaoting和pinned的配置委派给不同的内部实现类,其中关于floating的有2个内部类分别是_SliverFloatingPinnedPersistentHeader和_SliverFloatingPersistentHeader,两个最后的实现逻辑类似,都会创建同一个Element实现效果。


_SliverFloatingPersistentHeader


以_SliverFloatingPersistentHeader的举例看逻辑


class _SliverFloatingPersistentHeader extends _SliverPersistentHeaderRenderObjectWidget {
  const _SliverFloatingPersistentHeader({
    required super.
delegate,
  })
 : super(
    floating: 
true,
  )
;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context) {
    return _RenderSliverFloatingPersistentHeaderForWidgets(
      vsync: delegate.vsync,
      snapConfiguration: delegate.snapConfiguration,
      stretchConfiguration: delegate.stretchConfiguration,
      showOnScreenConfiguration: delegate.showOnScreenConfiguration,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderSliverFloatingPersistentHeaderForWidgets renderObject) {
    renderObject.vsync = delegate.vsync;
    renderObject.snapConfiguration = delegate.snapConfiguration;
    renderObject.stretchConfiguration = delegate.stretchConfiguration;
    renderObject.showOnScreenConfiguration = delegate.showOnScreenConfiguration;
  }
}

其中的核心是createRenderObject和updateRenderObject,后者的作用热重载和更新配置信息,前者的作用的是创建一个_RenderSliverFloatingPersistentHeaderForWidgets,在这个RenderObject它内部处理了复杂的逻辑,例如:



  • 响应滚动方向变化;

  • 控制 header 出现/消失动画;

  • 通过 ScrollPosition.hold() 暂停用户滚动;

  • 使用 _FloatingHeaderState 管理动画控制器(AnimationController)。



记住这个RenderObject,后面还会见到它



_SliverPersistentHeaderRenderObjectWidget


可以看到_SliverFloatingPersistentHeader继承于_SliverPersistentHeaderRenderObjectWidget,先看看它的代码


abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWidget {
  const _SliverPersistentHeaderRenderObjectWidget({
    required 
this.delegate,
    
this.floating = false,
  })
;

  final SliverPersistentHeaderDelegate delegate;
  final bool floating;

  @override
  _SliverPersistentHeaderElement createElement() => _SliverPersistentHeaderElement(this, floating: floating);

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin createRenderObject(BuildContext context);

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
    super.debugFillProperties(description);
    description.add(
      DiagnosticsProperty(
        'delegate',
        delegate,
      ),
    );
  }
}

核心逻辑是通过createElement创建了_SliverPersistentHeaderElement,到这里为止刚好对应上flutter渲染树中的三层架构:


层级类名作用
Widget_SliverPersistentHeaderRenderObjectWidget定义静态配置(delegate、floating)
Element_SliverPersistentHeaderElement管理生命周期与子节点(build、mount、update)
RenderObject_RenderSliverPersistentHeaderForWidgetsMixin真正参与布局绘制

简单的说就是:



  • _SliverPersistentHeaderRenderObjectWidget 负责描述,

  • _SliverPersistentHeaderElement 负责执行,

  • _RenderSliverPersistentHeaderForWidgetsMixin 负责绘制。


_SliverPersistentHeaderElement


那这个Element长啥样呢


class _SliverPersistentHeaderElement extends RenderObjectElement {
  _SliverPersistentHeaderElement(
    _SliverPersistentHeaderRenderObjectWidget super.widget, {
    this.floating = false,
  });

  final bool floating;

  @override
  _RenderSliverPersistentHeaderForWidgetsMixin get renderObject => super.renderObject as _RenderSliverPersistentHeaderForWidgetsMixin;

  @override
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    renderObject._element = this;
  }

  @override
  void unmount() {
    renderObject._element = null;
    super.unmount();
  }

  @override
  void update(_SliverPersistentHeaderRenderObjectWidget newWidget) {
    ...
  }

  @override
  void performRebuild() {
    super.performRebuild();
    renderObject.triggerRebuild();
  }

  Element? child;

  void _build(double shrinkOffset, bool overlapsContent) {
    owner!.buildScope(this, () {
      final _SliverPersistentHeaderRenderObjectWidget sliverPersistentHeaderRenderObjectWidget = widget as _SliverPersistentHeaderRenderObjectWidget;
      child = updateChild(
        child,
        floating
          ? _FloatingHeader(child: sliverPersistentHeaderRenderObjectWidget.delegate.build(
            this,
            shrinkOffset,
            overlapsContent
          ))
          : sliverPersistentHeaderRenderObjectWidget.delegate.build(this, shrinkOffset, overlapsContent),
        null,
      );
    });
  }

 ...
}

大致的流程是这样的



  • RenderSliverPersistentHeader.performLayout() 在滚动时触发;

  • 它会调用 _element._build(shrinkOffset, overlapsContent);

  • _build() 会重新构建 header 对应的 Widget;

  • 若 floating 模式,则额外包一层 _FloatingHeader;

  • 通过 updateChild() 更新或替换当前子 Element;

  • 生成的 child 会对应到 renderObject.child。


_FloatingHeader


class _FloatingHeaderState extends State<_FloatingHeader{
  ScrollPosition? _position;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    _position = Scrollable.maybeOf(context)?.position;
    if (_position != null) {
      _position!.isScrollingNotifier.addListener(_isScrollingListener);
    }
  }

  @override
  void dispose() {
    if (_position != null) {
      _position!.isScrollingNotifier.removeListener(_isScrollingListener);
    }
    super.dispose();
  }

  RenderSliverFloatingPersistentHeader? _headerRenderer() {
    return context.findAncestorRenderObjectOfType();
  }

  void _isScrollingListener() {
    assert(_position != null);

    // When a scroll stops, then maybe snap the app bar int0 view.
    // Similarly, when a scroll starts, then maybe stop the snap animation.
    // Update the scrolling direction as well for pointer scrolling updates.
    final RenderSliverFloatingPersistentHeader? header = _headerRenderer();
    if (_position!.isScrollingNotifier.value) {
      header?.updateScrollStartDirection(_position!.userScrollDirection);
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStopSnapAnimation(_position!.userScrollDirection);
    } else {
      // Only SliverAppBars support snapping, headers will not snap.
      header?.maybeStartSnapAnimation(_position!.userScrollDirection);
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

这里面监听滚动状态并控制吸附动画的触发/停止,而控制吸附动画的触发和停止的就是RenderSliverFloatingPersistentHeader,也就是前面_RenderSliverFloatingPersistentHeaderForWidgets所继承的类


_RenderSliverFloatingPersistentHeaderForWidgets


void updateScrollStartDirection(ScrollDirection direction) {
  _lastStartedScrollDirection = direction;
}

void maybeStopSnapAnimation(ScrollDirection direction) {
  _controller?.stop();
}

void maybeStartSnapAnimation(ScrollDirection direction) {
  final FloatingHeaderSnapConfiguration? snap = snapConfiguration;
  if (snap == null) {
    return;
  }
  if (direction == ScrollDirection.forward && _effectiveScrollOffset! <= 0.0) {
    return;
  }
  if (direction == ScrollDirection.reverse && _effectiveScrollOffset! >= maxExtent) {
    return;
  }

  _updateAnimation(
    snap.duration,
    direction == ScrollDirection.forward ? 0.0 : maxExtent,
    snap.curve,
  );
  _controller?.forward(from: 0.0);
}

void _updateAnimation(Duration duration, double endValue, Curve curve) {
  assert(
    vsync != null,
    'vsync must not be null if the floating header changes size animatedly.',
  );

  final AnimationController effectiveController =
    _controller ??= AnimationController(vsync: vsync!, duration: duration)
      ..addListener(() {
          if (_effectiveScrollOffset == _animation.value) {
            return;
          }
          _effectiveScrollOffset = _animation.value;
          markNeedsLayout();
        });

  _animation = effectiveController.drive(
    Tween(
      begin: _effectiveScrollOffset,
      end: endValue,
    ).chain(CurveTween(curve: curve)),
  );
}

可以看到核心思路就是: 创建 AnimationController



  • 如果 _controller 为空,则创建一个新的,绑定 vsync(防止动画掉帧);

  • 添加监听器,每帧更新 _effectiveScrollOffset 并调用 markNeedsLayout() 通知 RenderObject 重新布局。 创建 Tween + Curve

  • _animation 表示 header 从当前偏移量 _effectiveScrollOffset 到目标 endValue 的动画;

  • 使用 CurveTween 实现动画曲线(如 easeInOut)。 动画驱动布局

  • 每次动画值变化,RenderObject 会重新计算 header 的位置;

  • _effectiveScrollOffset 在 Render 层直接影响 layout 时 header 的显示/收缩状态。



那为什么SliverPersistentHeader的滑动会被优先消费呢?



@override
void performLayout() {
  final SliverConstraints constraints = this.constraints;
  final double maxExtent = this.maxExtent;
  final bool overlapsContent = constraints.overlap > 0.0;
  layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
  final double effectiveRemainingPaintExtent = math.max(0, constraints.remainingPaintExtent - constraints.overlap);
  final double layoutExtent = clampDouble(maxExtent - constraints.scrollOffset, 0.0, effectiveRemainingPaintExtent);
  final double stretchOffset = stretchConfiguration != null ?
    constraints.overlap.abs() :
    0.0;
  geometry = SliverGeometry(
    scrollExtent: maxExtent,
    paintOrigin: constraints.overlap,
    paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
    layoutExtent: layoutExtent,
    maxPaintExtent: maxExtent + stretchOffset,
    maxScrollObstructionExtent: minExtent,
    cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
    hasVisualOverflowtrue// Conservatively say we do have overflow to avoid complexity.
  );
}

其中layoutExtent的计算就是Header “优先消费滚动”的关键:



  • constraints.scrollOffset:代表当前 sliver 被上层滚动消耗的距离。

  • maxExtent:header 最大高度。

  • 当滚动时,scrollOffset 增大 ⇒ layoutExtent 减小 ⇒ header 收起;

  • 当滚动向下 ⇒ scrollOffset 减小 ⇒ layoutExtent 增大 ⇒ header 露出。


在header尚未完全隐藏(scrollOffset < maxExtent)之前,layoutExtent仍然大于0,意味着这个Header还在继续“吃掉”scrollOffset,下一个Sliver还拿不到这个滚动距离。


换句话说


Header 在Layout阶段主动根据scrollOffset调整可见高度,并在未完全隐藏时持续消耗滚动距离,导致下层列表“迟迟不动”——这就是“优先消费滑动”的根本原因。


利用机制解决问题


那我们又希望有这层动画效果,又不希望滑动被提前消费应该怎么做呢,思路有很多种



  • 重写sliver,去除这层消费

  • 手动接收滑动的offset,模仿实现顶部吸附的动画效果

  • 利用sliver接收的滑动实现我们需要的动画效果


第一种情况下sliver中的很多类是内部类,需要手动复制出来,成本极高


第二种思路需要手动兼容和原本布局的滑动冲突情况


在最快、最简思路下,第三种方案应该是最优解


class CustomSnapHeaderDemo extends StatefulWidget {
  const CustomSnapHeaderDemo({super.key});

  @override
  State createState() => _CustomSnapHeaderDemoState();
}

class _CustomSnapHeaderDemoState extends State<CustomSnapHeaderDemo{
  late final ScrollController _scrollController;
  late ScrollPosition _scrollPosition;

  /// header 的高度
  static const double _headerExtent = 120.0;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();

    /// 等待第一帧绘制后再拿到 ScrollPosition
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _scrollPosition = _scrollController.position;
      // 监听滚动状态变化
      _scrollPosition.isScrollingNotifier.addListener(_onScrollStateChanged);
    });
  }

  @override
  void dispose() {
    _scrollPosition.isScrollingNotifier.removeListener(_onScrollStateChanged);
    _scrollController.dispose();
    super.dispose();
  }

  /// 滚动状态监听器
  void _onScrollStateChanged() {
    final isScrolling = _scrollPosition.isScrollingNotifier.value;
    if (!isScrolling) {
      // 滚动停止时触发吸附逻辑
      _maybeSnapHeader();
    }
  }

  /// 自定义吸附逻辑:
  /// 当 header 显示一半以上时,吸附到完全展开;
  /// 否则隐藏到底部。
  void _maybeSnapHeader() {
    // 当前滚动偏移
    final currentOffset = _scrollPosition.pixels;

    // header 最大可滚动距离
    final maxHeaderOffset = _headerExtent / 2;

    // 如果当前偏移量 < headerExtent,说明 header 仍部分可见
    if (currentOffset >= 0 && currentOffset <= maxHeaderOffset) {
      final visibleRatio = 1.0 - (currentOffset / maxHeaderOffset);
      if (visibleRatio > 0.5) {
        // 吸附展开
        _animateTo(0);
      } else {
        // 吸附隐藏
        _animateTo(maxHeaderOffset);
      }
    }
  }

  /// 平滑滚动到目标位置
  void _animateTo(double targetOffset) {
    _scrollController.animateTo(
      targetOffset,
      duration: const Duration(milliseconds: 250),
      curve: Curves.easeOut,
    )
;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('SliverPersistentHeader Demo')),
      body: CustomScrollView(
        controller: _scrollController,
        slivers: [
          SliverPersistentHeader(
            pinned: true,
            floating: false,
            delegate: _CustomHeaderDelegate(
              extent: _headerExtent,
            ),
          ),

          // 模拟长列表内容
          SliverList.builder(
            itemCount: 50,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text('Item $index'),
              );
            },
          ),
        ],
      ),
    );
  }
}

class _CustomHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double extent;

  _CustomHeaderDelegate({required this.extent});

  @override
  double get minExtent => extent / 2// 最小高度
  @override
  double get maxExtent => extent; // 最大高度

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    final percent = 1.0 - (shrinkOffset / maxExtent);
    return Container(
      color: Colors.blue,
      alignment: Alignment.center,
      child: const Text(
        
'自定义吸附 Header',
        style: TextStyle(
          color: Colors.white,
          fontSize: 
22,
          fontWeight: FontWeight.bold,
        )
,
      ),
    )
;
  }

  @override
  bool shouldRebuild(covariant _CustomHeaderDelegate oldDelegate) {
    return oldDelegate.extent != extent;
  }
}

作者:柿蒂
来源:juejin.cn/post/7564661612319293455
收起阅读 »

⚔️ ReentrantLock大战synchronized:谁是锁界王者?

一、选手登场!🎬 🔵 蓝方:synchronized(老牌选手) // synchronized:Java自带的语法糖 public synchronized void method() { // 临界区代码 } // 或者 public void ...
继续阅读 »

一、选手登场!🎬


🔵 蓝方:synchronized(老牌选手)


// synchronized:Java自带的语法糖
public synchronized void method() {
// 临界区代码
}

// 或者
public void method() {
synchronized(this) {
// 临界区代码
}
}

特点:



  • 📜 JDK 1.0就有了,资历老

  • 🎯 简单粗暴,写法简单

  • 🤖 JVM级别实现,自动释放

  • 💰 免费午餐,不需要手动管理


🔴 红方:ReentrantLock(新锐选手)


// ReentrantLock:JDK 1.5引入
ReentrantLock lock = new ReentrantLock();

public void method() {
lock.lock(); // 手动加锁
try {
// 临界区代码
} finally {
lock.unlock(); // 必须手动释放!
}
}

特点:



  • 🆕 JDK 1.5新秀,年轻有活力

  • 🎨 功能丰富,花样多

  • 🏗️ API级别实现,灵活强大

  • ⚠️ 需要手动管理,容易忘记释放


二、底层实现对决 💻


Round 1: synchronized的底层实现


1️⃣ 对象头结构(Mark Word)


Java对象内存布局:
┌────────────────────────────────────┐
│ 对象头 (Object Header) │
│ ┌─────────────────────────────┐ │
│ │ Mark Word (8字节) │ ← 存储锁信息
│ ├─────────────────────────────┤ │
│ │ 类型指针 (4/8字节) │ │
│ └─────────────────────────────┘ │
├────────────────────────────────────┤
│ 实例数据 (Instance Data) │
├────────────────────────────────────┤
│ 对齐填充 (Padding) │
└────────────────────────────────────┘

Mark Word在不同锁状态下的变化:


64位虚拟机的Mark Word(8字节=64位)

┌──────────────────────────────────────────────────┐
│ 无锁状态 (001) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ hashcode │ age │001│ 未锁定 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 偏向锁 (101) │
│ ┌────────────┬─────┬──┬──┬──┐ │
│ │ 线程ID │epoch│101│ 偏向锁 │
│ └────────────┴─────┴──┴──┴──┘ │
├──────────────────────────────────────────────────┤
│ 轻量级锁 (00) │
│ ┌────────────────────────────┬──┐ │
│ │ 栈中锁记录指针 │00│ 轻量级锁 │
│ └────────────────────────────┴──┘ │
├──────────────────────────────────────────────────┤
│ 重量级锁 (10) │
│ ┌────────────────────────────┬──┐ │
│ │ Monitor对象指针 │10│ 重量级锁 │
│ └────────────────────────────┴──┘ │
└──────────────────────────────────────────────────┘

2️⃣ 锁升级过程(重点!)


                    锁升级路径

无锁状态 偏向锁 轻量级锁 重量级锁
│ │ │ │
│ 第一次访问 │ 有竞争 │ 竞争激烈 │
├──────────────→ ├──────────────→ ├──────────────→ │
│ │ │ │
│ │ CAS失败 │ 自旋失败 │
│ │ │ │

🚶 一个人 🚶 还是一个人 🚶🚶 两个人 🚶🚶🚶 一群人
走路 (偏向这个人) 抢着走 排队走

详细解释:


阶段1:无锁 → 偏向锁


// 第一次有线程访问synchronized块
Thread-1第一次进入:
1. 对象处于无锁状态
2. Thread-1通过CAS在Mark Word中记录自己的线程ID
3. 成功!升级为偏向锁,偏向Thread-1
4. 下次Thread-1再来,发现Mark Word里是自己的ID,直接进入!
(就像VIP通道,不用检查)✨

生活比喻:
你第一次去常去的咖啡店☕,店员记住了你的脸。
下次你来,店员一看是你,直接给你做你的老口味,不用问!

阶段2:偏向锁 → 轻量级锁


Thread-2也想进入:
1. 发现偏向锁偏向的是Thread-1
2. Thread-1已经退出了,撤销偏向锁
3. 升级为轻量级锁
4. Thread-2通过CAS在栈帧中创建Lock Record
5. CAS将对象头的Mark Word复制到Lock Record
6. CAS将对象头指向Lock Record
7. 成功!获取轻量级锁 🎉

生活比喻:
咖啡店来了第二个客人,店员发现需要排队系统了。
拿出号码牌,谁先抢到谁先点单(自旋CAS)🎫

阶段3:轻量级锁 → 重量级锁


Thread-3、Thread-4、Thread-5也来了:
1. 多个线程竞争,CAS自旋失败
2. 自旋一定次数后,升级为重量级锁
3. 没抢到的线程进入阻塞队列
4. 需要操作系统介入,线程挂起(park)😴

生活比喻:
咖啡店人太多了!需要叫号系统 + 座位等待区。
没叫到号的人坐下来等,不用一直站着抢(操作系统介入)🪑

3️⃣ 字节码层面


public synchronized void method() {
System.out.println("hello");
}

字节码:


public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED 看这里!方法标记
Code:
stack=2, locals=1, args_size=1
0: getstatic #2
3: ldc #3
5: invokevirtual #4
8: return

同步块字节码:


public void method() {
synchronized(this) {
System.out.println("hello");
}
}

public void method();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter 进入monitor
4: getstatic #2
7: ldc #3
9: invokevirtual #4
12: aload_1
13: monitorexit 退出monitor
14: goto 22
17: astore_2
18: aload_1
19: monitorexit 异常时也要退出
20: aload_2
21: athrow
22: return

Round 2: ReentrantLock的底层实现


基于AQS(AbstractQueuedSynchronizer)实现:


// ReentrantLock内部
public class ReentrantLock {
private final Sync sync;

// 抽象同步器
abstract static class Sync extends AbstractQueuedSynchronizer {
// ...
}

// 非公平锁实现
static final class NonfairSync extends Sync {
final void lock() {
// 先CAS抢一次
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 进入AQS队列
}
}

// 公平锁实现
static final class FairSync extends Sync {
final void lock() {
acquire(1); // 直接排队,不插队
}
}
}

数据结构:


ReentrantLock

├─ Sync (继承AQS)
│ ├─ state: int (0=未锁,>0=重入次数)
│ └─ exclusiveOwnerThread: Thread (持锁线程)

└─ CLH队列
Head → Node1 → Node2 → Tail
↓ ↓
Thread2 Thread3
(等待) (等待)

三、功能对比大战 ⚔️


🏁 功能对比表


功能synchronizedReentrantLock胜者
加锁方式自动手动lock/unlocksynchronized ✅
释放方式自动(异常也会释放)必须手动finallysynchronized ✅
公平锁不支持支持公平/非公平ReentrantLock ✅
可中断不可中断lockInterruptibly()ReentrantLock ✅
尝试加锁不支持tryLock()ReentrantLock ✅
超时加锁不支持tryLock(timeout)ReentrantLock ✅
Condition只有一个wait/notify可多个ConditionReentrantLock ✅
性能(JDK6+)优化后差不多差不多平局 ⚖️
使用难度简单复杂,易出错synchronized ✅
锁信息不易查看getQueueLength()等ReentrantLock ✅

🎯 详细功能对比


1️⃣ 可中断锁


// ❌ synchronized不可中断
Thread t = new Thread(() -> {
synchronized(lock) {
// 即使调用t.interrupt(),这里也不会响应
while(true) {
// 死循环
}
}
});

// ✅ ReentrantLock可中断
Thread t = new Thread(() -> {
try {
lock.lockInterruptibly(); // 可响应中断
// ...
} catch (InterruptedException e) {
System.out.println("被中断了!");
}
});
t.start();
Thread.sleep(100);
t.interrupt(); // 可以中断!

2️⃣ 尝试加锁


// ❌ synchronized没有tryLock
synchronized(lock) {
// 要么拿到锁,要么一直等
}

// ✅ ReentrantLock可以尝试
if (lock.tryLock()) { // 尝试获取,不阻塞
try {
// 拿到锁了
} finally {
lock.unlock();
}
} else {
// 没拿到,去做别的事
System.out.println("锁被占用,我去干别的");
}

// ✅ 还支持超时
if (lock.tryLock(3, TimeUnit.SECONDS)) { // 等3秒
try {
// 拿到了
} finally {
lock.unlock();
}
} else {
// 3秒还没拿到,放弃
System.out.println("等太久了,不等了");
}

3️⃣ 公平锁


// ❌ synchronized只能是非公平锁
synchronized(lock) {
// 后来的线程可能插队
}

// ✅ ReentrantLock可选公平/非公平
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平锁(默认)

公平锁 vs 非公平锁:


非公平锁(吞吐量高):
Thread-1持锁 → Thread-2排队 → Thread-3排队

释放锁!

Thread-4刚好来了,直接抢!(插队)✂️
虽然Thread-2先来,但Thread-4先抢到

公平锁(先来后到):
Thread-1持锁 → Thread-2排队 → Thread-3排队

释放锁!

Thread-4来了,但要排队到最后!
Thread-2先到先得 ✅

4️⃣ 多个条件变量


// ❌ synchronized只有一个等待队列
synchronized(lock) {
lock.wait(); // 只有一个等待队列
lock.notify(); // 随机唤醒一个
}

// ✅ ReentrantLock可以有多个Condition
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 条件1:未满
Condition notEmpty = lock.newCondition(); // 条件2:非空

// 生产者
lock.lock();
try {
while (queue.isFull()) {
notFull.await(); // 等待"未满"条件
}
queue.add(item);
notEmpty.signal(); // 唤醒"非空"条件的线程
} finally {
lock.unlock();
}

// 消费者
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 等待"非空"条件
}
queue.remove();
notFull.signal(); // 唤醒"未满"条件的线程
} finally {
lock.unlock();
}

四、性能对决 🏎️


JDK 1.5时代:ReentrantLock完胜


JDK 1.5性能测试(100万次加锁):
synchronized: 2850ms 😓
ReentrantLock: 1200ms 🚀

ReentrantLock快2倍多!

JDK 1.6之后:synchronized反击!


JDK 1.6对synchronized做了大量优化:



  • ✅ 偏向锁(Biased Locking)

  • ✅ 轻量级锁(Lightweight Locking)

  • ✅ 自适应自旋(Adaptive Spinning)

  • ✅ 锁粗化(Lock Coarsening)

  • ✅ 锁消除(Lock Elimination)


JDK 1.8性能测试(100万次加锁):
synchronized: 1250ms 🚀
ReentrantLock: 1200ms 🚀

几乎一样了!

优化技术解析


1️⃣ 偏向锁


// 同一个线程反复进入
for (int i = 0; i < 1000000; i++) {
synchronized(obj) {
// 偏向锁:第一次CAS,后续直接进入
// 性能接近无锁!✨
}
}

2️⃣ 锁消除


public String concat(String s1, String s2) {
// StringBuffer是线程安全的,有synchronized
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

// JVM发现sb是局部变量,不可能有竞争
// 自动消除StringBuffer内部的synchronized!
// 性能大幅提升!🚀

3️⃣ 锁粗化


// ❌ 原代码:频繁加锁解锁
for (int i = 0; i < 1000; i++) {
synchronized(obj) {
// 很短的操作
}
}

// ✅ JVM优化后:锁粗化
synchronized(obj) { // 把锁提到循环外
for (int i = 0; i < 1000; i++) {
// 很短的操作
}
}

五、使用场景推荐 📝


优先使用synchronized的场景


1️⃣ 简单的同步场景


// 简单的计数器
private int count = 0;

public synchronized void increment() {
count++;
}

2️⃣ 方法级别的同步


public synchronized void method() {
// 整个方法同步,简单明了
}

3️⃣ 不需要高级功能


// 只需要基本的互斥,不需要tryLock、Condition等
synchronized(lock) {
// 业务代码
}

优先使用ReentrantLock的场景


1️⃣ 需要可中断的锁


// 可以响应中断,避免死锁
lock.lockInterruptibly();

2️⃣ 需要尝试加锁


// 拿不到锁就去做别的事
if (lock.tryLock()) {
// ...
}

3️⃣ 需要公平锁


// 严格按照先来后到
ReentrantLock fairLock = new ReentrantLock(true);

4️⃣ 需要多个条件变量


// 生产者-消费者模式
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

5️⃣ 需要获取锁的信息


// 查看等待的线程数
int waiting = lock.getQueueLength();
// 查看是否有线程在等待
boolean hasWaiters = lock.hasQueuedThreads();

六、常见坑点 ⚠️


坑1:ReentrantLock忘记unlock


// ❌ 危险!如果中间抛异常,永远不会释放锁
lock.lock();
doSomething(); // 可能抛异常
lock.unlock(); // 不会执行!💣

// ✅ 正确写法
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // 一定会执行
}

坑2:synchronized锁错对象


// ❌ 每次都是新对象,不起作用!
public void method() {
synchronized(new Object()) { // 💣 错误!
// 相当于没加锁
}
}

// ✅ 正确写法
private final Object lock = new Object();
public void method() {
synchronized(lock) {
// ...
}
}

坑3:锁的粒度太大


// ❌ 锁的范围太大,性能差
public synchronized void method() { // 整个方法都锁住
doA(); // 不需要同步
doB(); // 需要同步
doC(); // 不需要同步
}

// ✅ 缩小锁范围
public void method() {
doA();
synchronized(lock) {
doB(); // 只锁需要的部分
}
doC();
}

七、面试应答模板 🎤


面试官:synchronized和ReentrantLock有什么区别?


你的回答:


主要从实现层面和功能层面两个角度对比:


实现层面:



  1. synchronized是JVM层面的,基于monitor机制,通过对象头的Mark Word实现

  2. ReentrantLock是API层面的,基于AQS(AbstractQueuedSynchronizer)实现


功能层面,ReentrantLock更强大:



  1. 可中断:lockInterruptibly()可响应中断

  2. 可尝试:tryLock()非阻塞获取锁

  3. 可超时:tryLock(time)超时放弃

  4. 公平锁:可选择公平或非公平

  5. 多条件:支持多个Condition

  6. 可监控:可获取等待线程数等信息


性能对比:



  • JDK 1.6之前ReentrantLock性能更好

  • JDK 1.6之后synchronized做了大量优化(偏向锁、轻量级锁、锁消除、锁粗化),性能差不多

  • synchronized优化包括:无锁→偏向锁→轻量级锁→重量级锁的升级路径


使用建议:



  • 简单场景优先synchronized(代码简洁,自动释放)

  • 需要高级功能时用ReentrantLock(可中断、超时、公平锁等)


举个例子:
如果只是简单的计数器,用synchronized即可。但如果是银行转账系统,需要可中断、可超时,就应该用ReentrantLock。


八、总结 🎯


选择决策树:
需要同步?

Yes

┌─────────────┴─────────────┐
│ │
简单场景 复杂场景
(计数器、缓存等) (可中断、超时等)
│ │
synchronized ReentrantLock
│ │
✅ 简单 ✅ 功能强
✅ 自动释放 ⚠️ 需手动
✅ 性能好 ✅ 灵活

记忆口诀:



简单场景synchronized,

复杂需求ReentrantLock,

性能现在差不多,

根据场景来选择!🎵



最后一句话:

synchronized是"自动挡"🚗,简单好用;

ReentrantLock是"手动挡"🏎️,灵活强大!


作者:用户6854537597769
来源:juejin.cn/post/7563822304766427172
收起阅读 »

Compose 重组优化

1、重组优化的核心思想 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。 关键词:尽可能少、尽可能快 2、常见重组优化   其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见...
继续阅读 »

1、重组优化的核心思想



  • 定义:状态变化时,让尽可能少的可组合函数以尽可能快的速度执行完成。

  • 关键词:尽可能少、尽可能快


2、常见重组优化


  其实在前面介绍Compose的时候,我们也多少提到过一些重组优化,这里主要是将前面提到过的重组优化、实际开发中常见错误怎么重组优化以及Compose中还提供了哪些重组优化的API做一次汇总,帮助我们刚上手时“避坑”。


  2.1 控制重组范围:


让状态变化只影响“必要区域”


 2.1.1 拆分复杂的可组合函数: 避免“牵一发而动全身”



  • 优化前:因name对象未被缓存,每次重组后都会创建新的对象,进而导致名称Text()在每次点击后都会重组。


点击操作 -> age累加 -> Test()重组 -> 重新创建name -> name Text()重组。


@Composable
fun Test(){
val name = "Hello world!!"
//可观察状态
var age by remember { mutableIntStateOf(18) }

Column {
//名称
Text(text = name)
//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}


  • 优化后:点击操作后,与age依赖的函数只有AgeTest(),Test()和NameTest()函数不受其影响。


@Composable
fun Test(){
Column {
//名称
NameTest()
//年龄
AgeTest()
}
}

@Composable
fun NameTest() {
Text(text = "Hello world!!")
}
@Composable
fun AgeTest() {
//可观察状态
var age by remember { mutableIntStateOf(18) }
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}

 2.1.2 列表用key控制重组颗粒度:避免“批量无效重组”


在使用LazyColumn/LazyRow 未指定 key 时,默认用 “列表索引” 作为标识,列表增删 / 排序时会导致大量无关项重组。


如果我们没有指定key,那么默认key就是index,假如我们删除第一项(index =0),会导致后续所有的索性变更(即都会左移:2->1,1->0),从而导致全部重组。--此时后面item无内容变化


指定key后,Compose识别后面item无内容变化,不会重组。--重组数量从“N -> 1”


@Composable
fun ProductList(products: List<Product>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
// 指定 key 为 product.id:唯一标识每个列表项
items(
items = products,
key = { product -> product.id } // 核心优化:用唯一 ID 替代索引
) { product ->
ShopItem(
product = product,
isFavorite = false,
onFavoriteClick = {}
)
Divider()
}
}
}

  2.2 避免无效重组:    让“不变的状态”不发生重组


Compose 简介和基础使用1 简介 1.1 背景 2019 年 5 月(首次亮相)在 Google I/O 大会上, - 掘金
中2.4.5.2 保存界面状态方式章节中提到过


特性rememberrememberSaveable
重组时是否保留是(核心功能)是(继承 remember 的能力)
重建时是否保留否(状态随组件实例销毁)是(通过 Bundle 持久化)
适用数据类型任意类型基本类型、可序列化类型(或需自定义保存逻辑)
性能开销低(内存级保存)略高(涉及 Bundle 读写)
典型使用场景临时状态(如列表展开 / 折叠)需持久化的用户输入(如表单、设置)

  2.2.1 remember


remember 是Compose API提供的缓存接口,避免每次重组时重新创建对象或者重新计算。
如下,“val showName = "Hello world!!--$name"”写法上面分析过,每次点击后name Text()都会发生重组。通过remember 缓存,那么只有name发生变化时name Text()才会重组。


@Composable
fun Test(name: String){
//状态
// val showName = "Hello world!!--$name"
//remember 普通缓存
val showName by remember(name) { mutableStateOf("Hello world!!--$name") }
var age by remember { mutableIntStateOf(18) }

// rememberSaveable 跨配置状态缓存
val showName by rememberSaveable(name) { mutableStateOf("Hello world!!--$name") }
var age by rememberSaveable { mutableIntStateOf(18) }

Column {
//名称
Text(text = showName)

//年龄
Text(
modifier = Modifier.clickable(onClick = { //点击后累加
age++
} ),
text = "$age",)
}
}

  2.2.2 rememberSaveable


rememberSaveable 是Compose API提供的缓存接口,当状态需要在配置变更(如屏幕旋转、语言切换)后保留时,使用 rememberSaveable 可以实现跨配置的状态缓存,避免状态丢失和不必要的重新计算。


如上示例假设showName、age需要在屏幕旋转、语言切换后保留之前状态,那么就可以用rememberSaveable 缓存。


  2.2.3 rememberUpdatedState


副作用生命周期大于状态的变化周期(例如副作用中延迟、循环等),且副作用中需要获取最新的状态值。
分析:- LaunchedEffect(Unit)副作用中使用Unit表示没监听任何状态,所以只在首次重组时创建启动协程,后续重组不会再重新创建新的启动协程,并且旧的协程也不会被打断。



  • reportMessage 是可观察状态,内部直接通过副作用使用时,协程捕获到的是这个状态的引用,所以修改后内部延迟也能打印最新的值。而通过参数传递时传递的是具体的值(String),所以不使用rememberUpdatedState只能打印旧值,使用后rememberUpdatedState可以监听值的变化,保证副作用中打印的是最新的值。


@Composable
fun ReportMessageScreen() {
// 父组件管理的消息状态,可动态更新
var reportMessage by remember { mutableStateOf("初始消息") }

// 子组件:负责延迟上报消息
MessageReporter(currentMessage = reportMessage)

LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "内部上报: $reportMessage")
}

// 按钮:更新消息内容
Button(onClick = { reportMessage = "用户修改后的新消息" } ) {
Text(reportMessage)
}

}

@Composable
fun MessageReporter(currentMessage: String) {
Log.d("Report","MessageReporter----start---")
// 错误做法:不使用rememberUpdatedState
LaunchedEffect(Unit) {
delay(5000) // 延迟3秒上报
// 问题:即使currentMessage已更新,仍会上报初始值
Log.d("Report", "错误上报: $currentMessage")
}

// 正确做法:必须使用rememberUpdatedState
val latestMessage by rememberUpdatedState(currentMessage)
LaunchedEffect(Unit) {
delay(10000) // 延迟3秒上报
// 确保上报的是最新值
Log.d("Report", "正确上报: $latestMessage")
}
Log.d("Report","MessageReporter----end---")
}

//日志打印
//初始化
2025-09-11 20:27:26.742 6847-6847 D MessageReporter----start---
2025-09-11 20:27:26.749 6847-6847 D MessageReporter----end---
//点击后
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----start---
2025-09-11 20:27:29.077 6847-6847 D MessageReporter----end---
//延迟消息
2025-09-11 20:27:32.096 6847-6847 D 错误上报: 初始消息
2025-09-11 20:27:37.098 6847-6847 D 正确上报: 用户修改后的新消息

  2.2.4 derivedStateOf


通过派生状态的结果去重,避免因 “依赖状态频繁变化但结果不变” 导致的重组。


示例:只有当userName和password都不为空时才需要重组按钮


@Composable
fun LoginScreen() {
// 状态源1:用户名输入
var username by remember { mutableStateOf("") }
// 状态源2:密码输入
var password by remember { mutableStateOf("") }

//错误写法,每次输入username或者password时,isLoginEnabled都会导致按钮重组
//val isLoginEnabled = username.isNotEmpty() && password.isNotEmpty()

//正确写法 用derivedStateOf组合两个状态,判断按钮是否可点击
val isLoginEnabled by remember {
derivedStateOf {
// 同时依赖username和password两个状态
username.isNotEmpty() && password.isNotEmpty()
}
}

// 依赖isLoginEnabled的按钮
Button(
onClick = { /* 登录逻辑 */ },
enabled = isLoginEnabled
) {Text("登录")}
}

   2.2.5 标记稳定类型 :@Stable/@Immutable


自定义数据类未标记稳定类型,Compose 无法判断其是否变化,可能会 “过度谨慎” 地触发重组。
原因:Compose 默认认为 “未标记的自定义类是不稳定的”,即使所有属性都是val。


        2.2.5.1 @Stable/@Immutable 使用

所以如下未标记时,在Test()重组时,即使person对象本身和name、age没有发生变化,都可能导致name Text()或者age Text()发生重组(过度谨慎重组)


优化方法:添加@Stable/@Immutable标记,防止Compose因过度谨慎带来的不必要的重组。


// 未标记稳定类型的自定义数据类
data class Person(val name: String, val age: Int)

//@Immutable(完全不可变,name和age都是val不可变类型)
//data class Person(val name: String, val age: Int)

//@Stable(稳定类型(不一定完全不可变),name是var可变,age是不可变类型)
//data class Person(var name: String, val age: Int)
@Composable
fun Test(person: Person){
Column {
//名称
Text(text = person.name)

//年龄
Text(text = "${person.age}",)
}
}

        2.2.5.2 @Stable/@Immutable 区别


  • @Immutable 标记的完全不可变的类,只要引用没变Compose就认为内部数据一定没变,不需要重组。特性:


    类的所有属性都是val(不可变)



//正确
@Immutable
data class Book(
val id: Int, // val 不可变
val title: String // val 不可变
)

// 错误:包含 var 属性
@Immutable
data class Book(
val id: Int,
var title: String // var 可变,违反条件
)

// Book 对象做为Composable入参
@Composable
fun Test(book: Book){
Column {
//名称
Text(text = person.title)
}
}

所有属性类型本身也是不可变的(或被@Immutable标记)


// 自定义不可变类(满足 @Immutable 条件)
@Immutable
data class Author(
val name: String, // String 是不可变类型
val age: Int // Int 是不可变类型
)

// 引用 @Immutable 类型的属性
@Immutable
data class Book(
val id: Int,
val title: String,
// Author 被 @Immutable 标记,满足条件
// 如果Author 没有被 @Immutable 标记,则不满足条件
val author: Author
)

// 错误:属性类型是可变的 MutableList
@Immutable
data class Book(
val id: Int,
val tags: MutableList<String> // MutableList 是可变类型,违反条件
)

类本身没有任何可修改状态(包括间接引用对象)


// 最底层:不可变类型
@Immutable
data class Address(
val city: String,
val street: String
)

// 中间层:引用不可变类型
@Immutable
data class User(
val name: String,
val address: Address // Address 是 @Immutable 类型
)

// 顶层:引用不可变类型
@Immutable
data class Order(
val id: Int,
val user: User // User 是 @Immutable 类型
)


  • @Stable 标记稳定的类(可存在可变属性),引用没变且内部状态能被追踪,Compose就能精准判断只有内部状态发生变化时才触发重组,避免无效重组。特性:



    • 类中存在可变属性var

    • 可变属性必须是可被追踪的




@Stable
class User {
// var age = 18 普通变量,不可追踪、被观察,变化后不会触发重组
var age by mutableStateOf(18) // 变化可追踪
}

// 用 User 作为入参的 Composable
@Composable
fun UserInfo(user: User) {
Text("年龄:${user.age}") // 依赖 user.age
}

也就是说要么引用变了(肯定要检查并重组),要么内部状态变了(Compose 能感知到),不会出现引用和内部状态都不变的情况下重组了,也不会出现 “状态变了但 Compose 不知道” 的情况。因此 Compose 可以放心地优化重组逻辑,既不会漏更 UI,也不会做无用功。


       2.2.5.3 总结

使用@Stable/@Immutable标记自定义类目的:因Compose 默认认为 “未标记的自定义类是不稳定的”,可能会发生”过度谨慎“重组。- 添加@Immutable注解完全不可变的类,Compose只有引用对象发生变化时需要重组,自定义类中不可存在可变属性。



  • 添加@Stable注解稳定的类(可存在可变属性),Compose只有引用对象发生变化或内部状态发生变化时需要重组,自定义类中允许存在可变属性。


    2.2.6 snapshotFlow      高频防抖


@Composable
fun SearchInput() {
var searchQuery by remember { mutableStateOf("") }

// 错误:每次输入字符都会触发重组,直接执行搜索,高频调用
//LaunchedEffect(searchQuery) {
// // 模拟搜索网络请求
// Log.d("Search", "搜索:$searchQuery")
// }

// 正确:将状态转为Flow,添加300ms防抖,仅停止输入后执行
LaunchedEffect(Unit) {
snapshotFlow { searchQuery } // 转换Compose状态为Flow
.debounce(300) // 防抖:300ms内无变化才继续
.collect { query ->
if (query.isNotEmpty()) {
Log.d("Search", "搜索:$query") // 仅停止输入后执行
}
}
}


TextField(
value = searchQuery,
onValueChange = { searchQuery = it },
label = { Text("输入搜索内容") }
)
}

  2.3 优化重组效率:


让必须重组的过程 “更快”


    2.3.1 减少可组合函数内的耗时操作


可组合函数应只做 “描述 UI” 的轻量操作,禁止在其中直接执行 IO、网络请求、复杂计算。


@Composable
fun UserProfile(userId: String) {
//错误示例,在组合函数中直接请求网络,每次重组时都会发起网络请求
//fetchDataFromNetwork() // 网络请求(副作用)

var user by remember { mutableStateOf<User?>(null) }

// 副作用:网络请求,依赖 userId(userId 变化时会重新执行)
LaunchedEffect(userId) {
// 耗时操作放在协程中,不阻塞主线程
user = api.fetchUser(userId) // 网络请求(副作用)
}

if (user != null) {
Text("Name: ${user?.name}")
} else {
CircularProgressIndicator()
}
}

    2.3.2 避免在重组中创建新对象


每次重组时创建新对象(如Lambda)会被 Compose 视为 “参数变化”,触发子组件重组。      温故而知新,之前实际开发中也都没注意到这些。- Lambda


//错误示例
@Composable
fun UserProfile(user: User) {
// 每次重组都会创建新的 Lambda 实例
Button(onClick = {
// 处理点击事件
navigateToUserDetail(user.id)
}) {
Text("查看详情")
}
}

//正确示例
@Composable
fun UserProfile(user: User) {
// 无依赖的 remember,仅在首次组合时创建一次 Lambda
val onClick = remember {
{ navigateToUserDetail(user.id) }
}

Button(onClick = onClick) {
Text("查看详情")
}
}
```
```

作者:用户06090525522
来源:juejin.cn/post/7559435122451693622
收起阅读 »

Compose 页面沉浸式体验适配

沉浸式 所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式: 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果; 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。 实现方案 创建一个 And...
继续阅读 »

沉浸式


所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式:



  • 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果;

  • 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。


实现方案


创建一个 Android Compose 项目,会默认生成 MainActivity 的代码:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

enableEdgeToEdge


在 onCreate 中会默认调用 enableEdgeToEdge(),这个方法是 ComponentActivity 的拓展方法,用来将 Activity 的内容延展到边缘,将状态栏设置为透明,导航栏根据导航模式呈现不同的效果,为这个 Activity 添加一个灰色背景,效果如下:


Screenshot_1729824579.png


Screenshot_1729822985.png


Screenshot_1729824516.png


这是三种导航模式的显示效果,导航模式可以在设置中更改:


Screenshot_1729822926.png


可以看出三种导航模式显示效果略有不同,双按钮导航和三按钮导航模式下,导航栏会有系统配置的蒙层。
而手势导航模式下,Activity 内容的背景是延伸到状态栏和导航栏的。


enableEdgeToEdge() 是 ComponentActivity 的拓展方法:


/**
* 对这个 ComponentActivity 开启边到边的显示
*
* 要使用默认样式进行设置,在你的 Activity's onCreate 方法中调用这个方法:
* ```
* override fun onCreate(savedInstanceState: Bundle?) {
* enableEdgeToEdge()
* super.onCreate(savedInstanceState)
* ...
* }
* ```
*
* 默认样式会在系统能够强制实施对比度的时候(在 API 29 及以上版本),把系统栏设置为透明背景。
* 在旧的平台上(只有 三按钮导航、双按钮导航模式),会应用一个类似的遮光层以确保与系统栏有对比度。
* See [SystemBarStyle] for more customization options.
*
* @param statusBarStyle The [SystemBarStyle] for the status bar.
* @param navigationBarStyle The [SystemBarStyle] for the navigation bar.
*/

@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
,
navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
val view = window.decorView
val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
val impl = Impl ?: if (Build.VERSION.SDK_INT >= 29) {
EdgeToEdgeApi29()
} else if (Build.VERSION.SDK_INT >= 26) {
EdgeToEdgeApi26()
} else if (Build.VERSION.SDK_INT >= 23) {
EdgeToEdgeApi23()
} else if (Build.VERSION.SDK_INT >= 21) {
EdgeToEdgeApi21()
} else {
EdgeToEdgeBase()
}.also { Impl = it }
impl.setUp(
statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
)
}

这个方法的注释中也描述三按钮和双按钮导航模式会有遮光层。


SystemBarStyle


enableEdgeToEdge() 方法中无论是导航栏还是状态栏的 Style 都是 SystemBarStyle 类型,SystemBarStyle 提供默认的系统风格,并且具有自动监测 dark 模式的能力。


SystemBarStyle 源码大致如下:


/**
* [enableEdgeToEdge] 中使用的状态栏或导航栏的样式。
*/

class SystemBarStyle private constructor(
private val lightScrim: Int,
internal val darkScrim: Int,
internal val nightMode: Int,
internal val detectDarkMode: (Resources) -> Boolean
) {

companion object {
@JvmStatic
@JvmOverloads
fun auto(
@ColorInt lightScrim: Int,
@ColorInt darkScrim: Int,
detectDarkMode: (Resources) -> Boolean = { resources ->
(resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK)
==
Configuration.UI_MODE_NIGHT_YES
}
): SystemBarStyle

@JvmStatic
fun dark(@ColorInt scrim: Int): SystemBarStyle

@JvmStatic
fun light(@ColorInt scrim: Int, @ColorInt darkScrim: Int): SystemBarStyle
}

internal fun getScrim(isDark: Boolean) = if (isDark) darkScrim else lightScrim

internal fun getScrimWithEnforcedContrast(isDark: Boolean): Int {
return when {
nightMode == UiModeManager.MODE_NIGHT_AUTO -> Color.TRANSPARENT
isDark -> darkScrim
else -> lightScrim
}
}
}

SystemBarStyle 提供了三个初始化方法,auto、dark、light,auto,三个方法的行为各不相同。


SystemBarStyle.auto


写个例子:


class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.dark(Color.Red.toArgb()) // set color for navigationBar
)
setContent {
ImmersiveDemoTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Spacer(modifier = Modifier.fillMaxSize().background(Color.Cyan))
Greeting(
name = "Android"
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

效果如下:


Screenshot_1729824579.png


Screenshot_1729824483.png


Screenshot_1729822956.png


在 API 级别 29 及以上,auto 方法在手势导航的情况下是透明的,设置的颜色不会生效。


在三按钮和双按钮导航模式下,系统将自动应用默认的遮光层。请注意,指定的颜色都不会被使用。在 API 级别 28 及以下,导航栏将根据暗黑模式是否开启来展示指定的颜色。



  • lightScrim 当应用处于浅色模式时用于背景的遮光层颜色。

  • darkScrim 当应用处于深色模式时用于背景的遮光层颜色。这也用于系统图标颜色始终为浅色的设备。


SystemBarStyle.dark


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。参数 scrim 用于背景的遮光层颜色。为了与浅色系统图标形成对比,它应该是深色的。


dark 模式很简单,无论什么导航模式、主题模式,他都显示设置的颜色。


Screenshot_1729828540.png


SystemBarStyle.light


创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。



  • 参数 scrim 用于背景的遮光层颜色。为了与深色系统图标形成对比,它应该是浅色的。

  • 参数 darkScrim 在系统图标颜色始终为浅色的设备上用于背景的遮光层颜色。它应该是深色的。


与 dark 不同,应用可以强制设置为 light 模式,而不用随系统的主题模式变化而变化,此时 darkScrim 生效。其他情况下使用 scrim。


系统栏背景遮光层


在上面的内容中,我们知道系统会给导航栏和状态栏设置一个遮光层,导航栏和状态栏会随着系统的导航模式和主题模式而变化。


但实际上应用希望呈现沉浸式的效果,就需要无论在上面导航模式、主题模式下都呈现与内容相同的颜色效果,所以需要去掉导航栏和状态栏的遮罩。


当我们什么也不设置,只调用 enableEdgeToEdge() 时,是这样的:


Screenshot_1729835423.png


调用去掉导航栏遮罩效果:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
}

isNavigationBarContrastEnforced 属性可以关闭强制使用导航栏遮罩,源码如下:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保导航栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保导航栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceNavigationBarContrast
* @see #isNavigationBarContrastEnforced
* @see #setNavigationBarColor
*/

public void setNavigationBarContrastEnforced(boolean enforceContrast) {
}

同样地,对于状态栏也有相同的属性:


    /**
* 当请求完全透明的背景时,设置系统是否应该确保栏有足够的对比度
*
* 如果设置为这个值,系统将确定是否需要一个遮光层来确保状态栏与这个应用的内容有足够的对比度,
* 并相应地设置一个适当的有效的导航栏背景颜色。
*
* 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
*
* @see android.R.attr#enforceStatusBarContrast
* @see #isStatusBarContrastEnforced
* @see #setStatusBarColor
*/

public void setStatusBarContrastEnforced(boolean ensureContrast) {
}

所以去掉遮光层效果如下:


if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// 去掉导航栏遮罩
window.isNavigationBarContrastEnforced = false
// 去掉状态栏遮罩
window.isStatusBarContrastEnforced = false
}

系统栏前景色


在状态栏和导航栏中有一些图标,比如状态栏中的电量图标、手势导航模式下的导航条图标,这些图标会随着系统主题(dark or light)变化为深色 icon 或是浅色 icon,



  • 当系统为 dark 主题模式下,icon 是浅色的,以和背景达成一种对比效果;

  • 当系统为 light 主题模式下,icon 是深色的。


		/**
* 如果为 true,则将状态栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 23 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightStatusBars()
*/

public void setAppearanceLightStatusBars(boolean isLight) {
mImpl.setAppearanceLightStatusBars(isLight);
}

同样地,有对导航栏设置的 API:


    /**
* 如果为 true,则将导航栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
* 如果为 false,则恢复为默认外观。
*
* 此方法在 API 级别小于 26 时没有效果。
*
* 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
*
* @see #isAppearanceLightNavigationBars()
*/

public void setAppearanceLightNavigationBars(boolean isLight) {
mImpl.setAppearanceLightNavigationBars(isLight);
}

完整的设置方法:


val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.isAppearanceLightStatusBars = false
windowInsetsController.isAppearanceLightNavigationBars = false

作者:自动化BUG制造器
来源:juejin.cn/post/7429611142706855948
收起阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function

web
深入理解 JavaScript 报错:TypeError: undefined is not a function 在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息: 🚀Taimili 艾米莉 ( 一款免费开源的 taimi...
继续阅读 »

深入理解 JavaScript 报错:TypeError: undefined is not a function


在日常的 JavaScript 开发中,几乎每个人都见过这条令人熟悉又头疼的错误信息:


🚀Taimili 艾米莉 ( 一款免费开源的 taimili.com )


艾米莉 是一款优雅便捷的  GitHub Star 管理和加星工具 ,基于 PHP & javascript 构建, 能对github 得 star fork follow watch 管理和提升,最适合github 的深度用户


image.png


作者:开源之眼

链接:juejin.cn/post/755906…

来源:稀土掘金

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。



TypeError: undefined is not a function



这行报错简短却致命,尤其当代码行数成千上万时,找到问题根源往往需要一点侦探技巧。本文将从原理、常见原因、排查方法和最佳实践四个方面深入讲解这一错误。




一、错误的本质是什么?


首先要知道:

在 JavaScript 中,一切几乎都是对象,包括函数。

当你调用一个变量并在后面加上 () 时,JavaScript 会假设该变量是一个函数对象,并尝试执行它。


let fn;
fn(); // ❌ TypeError: fn is not a function

在上面的例子中,fn 的值是 undefined,但我们却尝试执行它,于是引发了经典错误:



TypeError: undefined is not a function



简单来说:



“你正在试图执行一个并不是函数的东西。”





二、常见的触发场景


让我们来看一些在实际项目中常见的触发情境。


1. 调用未定义的函数


sayHello(); // ❌ TypeError: sayHello is not a function

var sayHello = function() {
console.log("Hello");
};


原因var 声明的变量会提升,但赋值不会。执行到函数调用时,sayHello 还是 undefined



✅ 正确写法:


function sayHello() {
console.log("Hello");
}
sayHello(); // ✅ Hello

或者:


const sayHello = () => console.log("Hello");
sayHello(); // ✅ Hello



2. 调用了对象上不存在的方法


const user = {};
user.login(); // ❌ TypeError: user.login is not a function


原因user 对象没有 login 方法,访问结果是 undefined



✅ 正确做法:


const user = {
login() {
console.log("User logged in");
}
};
user.login(); // ✅ User logged in



3. 第三方库或异步加载未完成


// 某个库尚未加载完成
myLibrary.init(); // ❌ TypeError: myLibrary.init is not a function


原因:脚本加载顺序错误或资源未加载完。



✅ 解决方案:


<script src="mylib.js" onload="initApp()"></script>

或使用现代模块化方式:


import myLibrary from './mylib.js';
myLibrary.init();



4. 被覆盖的函数名


let alert = "Hello";
alert("Hi"); // ❌ TypeError: alert is not a function


原因:内置函数被变量覆盖。



✅ 解决方案:


避免重名:


let message = "Hello";
window.alert("Hi"); // ✅



5. this 指向错误


const obj = {
run() {
console.log("Running");
}
};

const run = obj.run;
run(); // ❌ TypeError: undefined is not a function (在严格模式下)


原因this 丢失导致方法不再属于原对象。



✅ 解决方案:


const boundRun = obj.run.bind(obj);
boundRun(); // ✅ Running

或直接调用:


obj.run(); // ✅ Running



三、排查思路与调试技巧


当遇到这个错误时,不要慌。按照以下步骤排查:


✅ 1. 查看错误堆栈(stack trace)


浏览器控制台一般会指明出错的文件与行号。

打开 DevTools → Console → 点击错误行号,即可定位具体位置。


✅ 2. 打印变量类型


使用 typeof 或 console.log 检查被调用的变量:


console.log(typeof myFunc); // 应该输出 'function'

✅ 3. 检查函数定义顺序


尤其是在使用 var 或异步加载模块时,注意执行顺序。


✅ 4. 检查导入导出是否匹配


在模块化开发中,这类错误经常来自错误的导入:


// ❌ 错误示例
import { utils } from './utils.js';
utils(); // TypeError: utils is not a function

✅ 应确认模块导出方式:


// utils.js
export default function utils() {}

然后正确导入:


import utils from './utils.js';
utils(); // ✅



四、防止 “undefined is not a function” 的最佳实践



  1. 使用 const/let 替代 var — 避免变量提升造成的未定义调用

  2. 模块化代码结构 — 保证依赖先加载

  3. 给函数添加类型校验


    if (typeof fn === 'function') fn();


  4. 启用严格模式或 TypeScript — 提前发现类型问题

  5. 避免覆盖全局对象(如 alertconfirmsetTimeout 等)


作者:开源之眼
来源:juejin.cn/post/7563220648827715610
收起阅读 »

消息队列和事件驱动如何实现流量削峰

消息队列和事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。 特性消息队列 (Message Queue)事件驱动架构 (Event-Driven Architecture)​...
继续阅读 »

消息队列事件驱动架构不仅是实现流量削峰的关键技术,它们之间更是一种相辅相成、紧密协作的关系。下面这个表格可以帮您快速把握它们的核心联系与分工。


特性消息队列 (Message Queue)事件驱动架构 (Event-Driven Architecture)
核心角色实现事件驱动架构的技术工具传输机制一种架构风格设计模式
主要职责提供异步通信通道,负责事件的存储路由可靠传递定义系统各组件之间通过事件进行交互的规范
与流量削峰关系实现流量削峰的具体手段​(作为缓冲区)流量削峰是其在处理突发流量时的一种自然结果能力体现
协作方式事件驱动架构中,事件的生产与消费通常依赖消息队列来传递事件消息为消息队列的应用提供了顶层设计业务场景

🔌 消息队列:流量削峰的实现工具


消息队列在流量削峰中扮演着“缓冲区”或“蓄水池”的关键角色 。其工作流程如下:



  1. 接收请求​:当突发流量到来时,所有请求首先被作为消息发送到消息队列中暂存,而非直接冲击后端业务处理服务 。

  2. 平滑压力​:后端服务可以按照自身的最佳处理能力,以固定的、可控的速度从消息队列中获取请求并进行处理 。

  3. 解耦与异步​:这使得前端请求的接收和后端业务的处理完全解耦。用户可能瞬间收到“请求已接受”的响应,而实际任务则在后台排队有序执行 。


一个典型的例子是秒杀系统​ 。在短时间内涌入的海量下单请求会被放入消息队列。队列的长度可以起到限制并发数量的作用,超出系统容量的请求可以被快速拒绝,从而保护下游的订单、库存等核心服务不被冲垮,实现削峰填谷 。


🏗️ 事件驱动:流量削峰的指导架构


事件驱动架构是一种从更高层面设计系统交互模式的思想 。当某个重要的状态变化发生时(例如用户下单、订单支付成功),系统会发布一个事件​ 。其他关心此变化的服务会订阅这些事件,并触发相应的后续操作 。这种“发布-订阅”模式天然就是异步的。


在流量削峰的场景下,事件驱动架构的意义在于:



  • 设计上的解耦​:它将“触发动作的服务”和“执行动作的服务”从时间和空间上分离开。下单服务完成核心逻辑后,只需发布一个“订单已创建”的事件,而不需要同步调用库存服务、积分服务等。这本身就为引入消息队列作为事件总线来缓冲流量奠定了基础 。

  • 结果的可达性​:即使某个服务(如积分服务)处理速度较慢,也不会影响核心链路(如扣减库存)。事件会在消息队列中排队,等待积分服务按自己的能力处理,从而实现了服务间的流量隔离和削峰 。


🤝 协同工作场景


消息队列与事件驱动架构协同工作的场景包括:



  • 异步任务处理​:用户注册后,需要发送邮件和短信。注册服务完成核心逻辑后,发布一个“用户已注册”事件到消息队列。邮件服务和短信服务作为订阅者,异步消费该事件,实现异步处理 。

  • 系统应用解耦​:订单系统与库存系统之间通过消息队列解耦。订单系统下单后,将消息写入消息队列即可返回成功,库存系统再根据消息队列中的信息进行库存操作,即使库存系统暂时不可用,也不会影响下单 。

  • 日志处理与实时监控​:使用类似Kafka的消息队列收集应用日志,后续的日志分析、监控报警等服务订阅这些日志流进行处理,解决大量日志传输问题 。


💡 选型与注意事项


在选择和运用这些技术时,需要注意:



  • 技术选型​:不同消息队列有不同特点。​RabbitMQ​ 以消息可靠性见长;Apache Kafka​ 专为高吞吐量的实时日志流和数据管道设计,尤其适合日志处理等场景 ;RocketMQ​ 在阿里内部经历了大规模交易场景的考验 。

  • 潜在挑战​:



    • 复杂性增加​:需要维护消息中间件,并处理可能出现的消息重复、丢失、乱序等问题 。

    • 数据一致性​:异步化带来了最终一致性,需要考虑业务是否能接受 。

    • 系统延迟​:请求需要排队处理,用户得到最终结果的时间会变长,不适合所有场景。




作者:IT橘子皮
来源:juejin.cn/post/7563511245087506486
收起阅读 »

kotlin协程 容易被忽视的CompletableDeferred

CompletableDeferred是一个 可手动完成 的 Deferred, 它实现了 Deferred(可以 await()),也提供了 complete(value) / completeExceptionally(e) / cancel() 等方法,...
继续阅读 »

CompletableDeferred是一个 可手动完成 的 Deferred, 它实现了 Deferred(可以 await()),也提供了 complete(value) / completeExceptionally(e) / cancel() 等方法,并由外部触发结果。它创建后可能处于未完成状态,任意线程或协程都能把它完成或失败,等待方使用 await() 来获取结果。


一、与Deferred、suspendCancellableCoroutine和Channel的区别



  • vs Deferred(由 async 返回):async 的 Deferred 是由协程体自己完成;CompletableDeferred 允许外部完成。

  • vs suspendCancellableCoroutine:两者都能把回调桥接到协程,suspendCancellableCoroutine 适合一次性封装回调;CompletableDeferred 更适合“外部多方在不同时间完成/通知”的场景(比如事件总线、跨协程信号、测试用的手动完成)。

  • vs Channel:Channel 是多次消息传递而CompletableDeferred 是单次结果(一次性)。


二、经典应用场景


1、桥接回调


一般的桥接回调推荐用suspendCancellableCoroutine,但如果外部多方的桥接推荐用CompletableDeferred。下面是一个简单的网络请求的例子。


suspend fun Call.awaitResponse(): Response = suspendCancellableCoroutine { cont ->
    enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            if (cont.isActive) cont.resumeWithException(e)
        }

        override fun onResponse(call: Call, response: Response) {
//response记得use{}关闭
            if (cont.isActive) cont.resume(response)
        }
    })
    cont.invokeOnCancellation { cancel() }
}

如果你想用 CompletableDeferred(当回调可能多次触发或在别处完成时非常好):


fun makeRequest(): CompletableDeferred<String> {
val deferred = CompletableDeferred<String>()
httpClient.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
deferred.completeExceptionally(e)
}
override fun onResponse(call: Call, response: Response) {
val s = response.body?.string() ?: ""
deferred.complete(s)
response.close()
}
})
return deferred
}

// 使用处
lifecycleScope.launch {
try {
val result = makeRequest().await()
} catch (e: Throwable) { ... }
}

上面的桥接肯定推荐suspendCancellableCoroutine来完成,只是为了展示CompletableDeferred也可以完成回调的桥接。下面看一个多处回调桥接的例子:


// 使用 CompletableDeferred 桥接多回调
suspend fun wakeUpDevice(): Boolean = coroutineScope {
    val deferred = CompletableDeferred<Boolean>()
    val finished = AtomicBoolean(false)
val failCount = AtomicInteger(0)

    // 本地唤醒
    sendLocalWakeUp(
        onSuccess = {
            if (finished.compareAndSet(false, true)) {
                deferred.complete(true)
            }
        },

        onFail = {
            if (failCount.incrementAndGet() == 2) {
                deferred.complete(false)
            }
        }
    )

    // 云端唤醒
    sendCloudWakeUp(
        onSuccess = {
            if (finished.compareAndSet(false, true)) {
                deferred.complete(true)
            }
        },

        onFail = {
            if (failCount.incrementAndGet() == 2) {  
deferred.complete(false)            
}
        }
    )

    // 统一 suspend 等待结果
    deferred.await()
}




// 示例调用
fun main() = runBlocking {
    val result = wakeUpDevice()
    println("唤醒结果:$result")
}

上面就是多处回调桥接的例子,只要有一次成功就成功,都失败才算失败。


2、多生产者单消费者“谁先到就用谁”


complete函数只能被调用一次,后续调用无效,基于这个特性可以完成多生产者单消费者-谁先到就用谁的需求。一个简单的示例如下:


//两个异步来源(A、B)谁先返回就用谁的结果
val winner = CompletableDeferred<String>()
fun sourceA() { /* 异步获取 */ winner.complete("fromA") }
fun sourceB() { /* 异步获取 */ winner.complete("fromB") }
lifecycleScope.launch {
    val result = winner.await() // 谁先 complete 就是结果
}

3、协程间通知


这是一个比较巧妙的用法,例如一个协程做准备工作,另一个协程等待完成:


val ready = CompletableDeferred<Unit>()

launch {
prepareResources()
ready.complete(Unit)
}

launch {
ready.await() //挂起阻塞,等待准备(prepareResources)完成
startWork() //另一个协程的执行部分
}

扩展思维:ready.complete(Unit)还可以在用户输入后,点击按钮后等,可以发挥想象。


4、与select、超时结合


select 表达式同时等待多个挂起操作采用第一个完成的结果。


fun CoroutineScope.fetchWithFallbacks(): CompletableDeferred<String> {
val result = CompletableDeferred<String>()

launch {
try {
val data = select<String> {
primarySource().onAwait { it }
secondarySource().onAwait { it }
onTimeout(800) { throw TimeoutException("Timeout") }
}
result.complete(data)
} catch (e: Exception) {
// 启动异步恢复任务(不立即completeExceptionally)
launch {
delay(1000)
try {
val recovery = recoverySource()
result.complete(recovery)
} catch (e2: Exception) {
result.completeExceptionally(e2) // 真正失败
}
}
}
}

return result
}

三、取消与异常传播



  • 如果 CompletableDeferred 被 cancel(),等待的协程会收到 CancellationException。

  • 如果在完成前等待者被取消,完成者仍然可以 complete() —— 但 await() 不会再返回值(因为等待者已被取消)。

  • completeExceptionally(e) 会使 await() 抛出相同异常。

  • complete() 返回 true 或 false(表示是否第一次完成)。


四、注意事项与常见坑



  1. 不要泄漏未完成的 CompletableDeferred:把它长期暴露给全局可能会导致内存泄漏,尤其当负责完成它的组件被销毁时。

  2. 只在受信任的地方完成:不要让多个地方都可能完成且没序的代码混乱;若有竞态,记得检查 complete 的返回值。

  3. 避免把 CompletableDeferred 当作长生命周期队列:它是一次性完成的;若需要多次事件,请用 Channel / SharedFlow。

  4. 取消处理:如果等待者可能取消,使用 invokeOnCompletion 或 invokeOnCancellation 做清理(比如取消底层请求)。

  5. 完成后不要反复设置结果:complete 多次调用只有第一次有效,后续会返回 false。

  6. 与协程作用域的关系:CompletableDeferred 本身不是 Job,但它有 asJob() 可用于组合;也可以传入父 Job 构造(CompletableDeferred(parentJob))以便取消联动。


作者:TimeFine
来源:juejin.cn/post/7564485874727550976
收起阅读 »

那些前端老鸟才知道的秘密

web
前端老鸟才知道的秘密:void(0),这东西到底有什么用 那天我盯着同事的代码看了半天,心里默念:这货是不是写错了? 前几天 review 代码,看到一个小年轻写了这么一行: const foo = void 0; 我当时就乐了,心想:" 这孩子是不是被...
继续阅读 »

前端老鸟才知道的秘密:void(0),这东西到底有什么用



那天我盯着同事的代码看了半天,心里默念:这货是不是写错了?



前几天 review 代码,看到一个小年轻写了这么一行:


const foo = void 0;

我当时就乐了,心想:" 这孩子是不是被产品经理逼疯了?直接写undefined不香吗?非得整这出?"


但转念一想,不对啊,这写法我好像在哪儿见过... 仔细一琢磨,卧槽,这不就是前端老司机的暗号吗!


所以,void 0 到底是个啥?


简单来说,void 0就是强行返回 undefined的一种写法。


你可能会问:"那我直接写 undefined 不就完事了?干嘛要多此一举?"


问得好!这就要从前端开发的 "血泪史" 说起了。


那些年被 undefined 坑过的日子


在 JavaScript 的远古时期(其实就是 ES5 之前),undefined 这个变量是可以被重写的!


没错,你没听错,就是那个表示 "未定义" 的 undefined,它自己都可能被定义成别的东西...


// 在古老的浏览器里,你可以这么玩(现在别试了)
undefined = "我是谁?我在哪?";
console.log(undefined); // 输出:"我是谁?我在哪?"

这就很尴尬了 —— 你用来判断是否未定义的变量,自己都可能被篡改!


这时候,void 0就闪亮登场了。


void 0 的三大绝技


1. 绝对安全的 undefined


void操作符有个特点:不管后面接什么,都返回 undefined


void 0 // undefined
void "hello" // undefined
void {} // undefined
void function(){} // undefined

所以void 0就成了获取真正 undefined 的最可靠方式。


2. 阻止链接跳转的老司机


还记得以前写<a href="javascript:void(0)">吗?这就是为了防止点击链接后页面跳转。


虽然现在大家都用event.preventDefault()了,但这可是老一辈前端人的集体记忆啊!


3. 立即执行函数的替代方案


有些老代码里你会看到:


void function() {
// 立即执行的代码
}();

这其实是为了避免函数声明被误认为是语句开头。


现在还需要 void 0 吗?


说实话,在现代前端开发中,直接用undefined已经足够安全了。ES5 之后的规范规定 undefined 是只读的,不能再被重写。


但为什么还有老司机在用 void 0 呢?



  1. 习惯成自然:用了十几年,改不过来了

  2. 代码压缩void 0undefined字符更少

  3. 装逼必备:一看就是用 void 0 的,肯定是老鸟(手动狗头)


所以,到底用不用?


我的建议是:知道为什么用,比用什么更重要


如果你是为了代码风格统一,或者团队约定,用 void 0 没问题。


如果只是为了装老司机... 兄弟,真没必要。现在面试官看到 void 0,第一反应可能是:"这人是刚从 jQuery 时代穿越过来的吗?"




最后送大家一句话:技术选型就像穿衣服,合适比时髦更重要。  知道每个工具为什么存在,比你盲目跟风要强得多。


作者:hmfy
来源:juejin.cn/post/7563635016283668531
收起阅读 »

Java 中的 Consumer 与 Supplier 接口

异同分析 Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。 相同点 都是函数式接口(只有一个抽象方法) 都位于 java.util.function...
继续阅读 »

异同分析


Consumer 和 Supplier 是 Java 8 引入的两个重要函数式接口,位于 java.util.function 包中,用于支持函数式编程范式。


相同点



  1. 都是函数式接口(只有一个抽象方法)

  2. 都位于 java.util.function 包中

  3. 都用于 Lambda 表达式和方法引用

  4. 都在 Stream API 和 Optional 类中广泛使用


不同点


特性ConsumerSupplier
方法签名void accept(T t)T get()
参数接受一个输入参数无输入参数
返回值无返回值返回一个值
主要用途消费数据提供数据
类比方法中的参数方法中的返回值

详细分析与使用场景


Consumer 接口


Consumer 表示接受单个输入参数但不返回结果的操作。


import java.util.function.Consumer;
import java.util.Arrays;
import java.util.List;

public class ConsumerExample {
public static void main(String[] args) {
// 基本用法
Consumer<String> printConsumer = s -> System.out.println(s);
printConsumer.accept("Hello Consumer!");

// 方法引用方式
Consumer<String> methodRefConsumer = System.out::println;
methodRefConsumer.accept("Hello Method Reference!");

// 集合遍历中的应用
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(printConsumer);

// andThen 方法组合多个 Consumer
Consumer<String> upperCaseConsumer = s -> System.out.println(s.toUpperCase());
Consumer<String> decoratedConsumer = s -> System.out.println("*** " + s + " ***");

Consumer<String> combinedConsumer = upperCaseConsumer.andThen(decoratedConsumer);
combinedConsumer.accept("functional interface");

// 在 Optional 中的使用
java.util.Optional<String> optional = java.util.Optional.of("Present");
optional.ifPresent(combinedConsumer);
}
}

Consumer 的使用场景



  1. 遍历集合元素并执行操作

  2. 处理数据并产生副作用(如打印、保存到数据库)

  3. 在 Optional 中处理可能存在的值

  4. 组合多个操作形成处理链


Supplier 接口


Supplier 表示一个供应商,不需要传入参数但返回一个值。


import java.util.function.Supplier;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;

public class SupplierExample {
public static void main(String[] args) {
// 基本用法
Supplier<String> stringSupplier = () -> "Hello from Supplier!";
System.out.println(stringSupplier.get());

// 方法引用方式
Supplier<Double> randomSupplier = Math::random;
System.out.println("Random number: " + randomSupplier.get());

// 对象工厂
Supplier<List<String>> listSupplier = () -> java.util.Arrays.asList("A", "B", "C");
System.out.println("List from supplier: " + listSupplier.get());

// 延迟计算/初始化
Supplier<ExpensiveObject> expensiveObjectSupplier = () -> {
System.out.println("Creating expensive object...");
return new ExpensiveObject();
};

System.out.println("Supplier created but no object yet...");
// 只有在调用 get() 时才会创建对象
ExpensiveObject obj = expensiveObjectSupplier.get();

// 在 Stream 中生成无限流
Supplier<Integer> randomIntSupplier = () -> new Random().nextInt(100);
Stream.generate(randomIntSupplier)
.limit(5)
.forEach(System.out::println);

// 在 Optional 中作为备选值
java.util.Optional<String> emptyOptional = java.util.Optional.empty();
String value = emptyOptional.orElseGet(() -> "Default from supplier");
System.out.println("Value from empty optional: " + value);
}

static class ExpensiveObject {
ExpensiveObject() {
// 模拟耗时操作
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}
}
}

Supplier 的使用场景



  1. 延迟初始化或延迟计算

  2. 提供配置或默认值

  3. 生成测试数据或模拟对象

  4. 在 Optional 中提供备选值

  5. 创建对象工厂

  6. 实现惰性求值模式


实际应用示例


下面是一个结合使用 Consumer 和 Supplier 的示例:


import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.logging.Logger;

public class CombinedExample {
private static final Logger logger = Logger.getLogger(CombinedExample.class.getName());

public static void main(String[] args) {
// 创建一个数据处理器,结合了 Supplier 和 Consumer
processData(
() -> { // Supplier - 提供数据
// 模拟从数据库或API获取数据
return new String[] {"Data1", "Data2", "Data3"};
},
data -> { // Consumer - 处理数据
for (String item : data) {
System.out.println("Processing: " + item);
}
},
error -> { // Consumer - 错误处理
logger.severe("Error occurred: " + error.getMessage());
}
);
}

public static <T> void processData(Supplier<T> dataSupplier,
Consumer<T> dataProcessor,
Consumer<Exception> errorHandler)
{
try {
T data = dataSupplier.get(); // 从Supplier获取数据
dataProcessor.accept(data); // 用Consumer处理数据
} catch (Exception e) {
errorHandler.accept(e); // 用Consumer处理错误
}
}
}

总结



  • Consumer 用于表示接受输入并执行操作但不返回结果的函数,常见于需要处理数据并产生副作用的场景

  • Supplier 用于表示无需输入但返回结果的函数,常见于延迟计算、提供数据和工厂模式场景

  • 两者都是函数式编程中的重要构建块,可以组合使用创建灵活的数据处理管道

  • 在 Stream API、Optional 和现代 Java 框架中广泛应用


理解这两个接口的差异和适用场景有助于编写更简洁、更表达力的 Java 代码,特别是在使用 Stream API 和函数式编程范式时。


作者:往事随风去
来源:juejin.cn/post/7548717557531623464
收起阅读 »

线程安全过期缓存:手写Guava Cache🗄️

缓存是性能优化的利器,但如何保证线程安全、支持过期、防止内存泄漏?让我们从零开始,打造一个生产级缓存! 一、开场:缓存的核心需求🎯 基础需求 线程安全:多线程并发读写 过期淘汰:自动删除过期数据 容量限制:防止内存溢出 性能优化:高并发访问 生活类比: ...
继续阅读 »

缓存是性能优化的利器,但如何保证线程安全、支持过期、防止内存泄漏?让我们从零开始,打造一个生产级缓存!



一、开场:缓存的核心需求🎯


基础需求



  1. 线程安全:多线程并发读写

  2. 过期淘汰:自动删除过期数据

  3. 容量限制:防止内存溢出

  4. 性能优化:高并发访问


生活类比:


缓存像冰箱🧊:



  • 存储食物(数据)

  • 定期检查过期(过期策略)

  • 空间有限(容量限制)

  • 多人使用(线程安全)




二、版本1:基础线程安全缓存


public class SimpleCache<K, V> {

private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();

// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime; // 过期时间戳

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}

/**
* 存入缓存
*/

public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}

/**
* 获取缓存
*/

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null) {
return null;
}

// 检查是否过期
if (entry.isExpired()) {
cache.remove(key); // 惰性删除
return null;
}

return entry.value;
}

/**
* 删除缓存
*/

public void remove(K key) {
cache.remove(key);
}

/**
* 清空缓存
*/

public void clear() {
cache.clear();
}

/**
* 缓存大小
*/

public int size() {
return cache.size();
}
}

使用示例:


SimpleCache<String, User> cache = new SimpleCache<>();

// 存入缓存,5秒过期
cache.put("user:1", new User("张三"), 5000);

// 获取缓存
User user = cache.get("user:1"); // 5秒内返回User对象

Thread.sleep(6000);

User expired = cache.get("user:1"); // 返回null(已过期)

问题:



  • ❌ 过期数据需要访问时才删除(惰性删除)

  • ❌ 没有容量限制,可能OOM

  • ❌ 没有定时清理,内存泄漏




三、版本2:支持定时清理🔧


public class CacheWithCleanup<K, V> {

private final ConcurrentHashMap<K, CacheEntry<V>> cache =
new ConcurrentHashMap<>();

private final ScheduledExecutorService cleanupExecutor;

static class CacheEntry<V> {
final V value;
final long expireTime;

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}

public CacheWithCleanup() {
this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);

// 每秒清理一次过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}

public void put(K key, V value, long ttlMillis) {
cache.put(key, new CacheEntry<>(value, ttlMillis));
}

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null || entry.isExpired()) {
cache.remove(key);
return null;
}

return entry.value;
}

/**
* 定时清理过期数据
*/

private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

/**
* 关闭缓存
*/

public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}

改进:



  • ✅ 定时清理过期数据

  • ✅ 不依赖访问触发删除


问题:



  • ❌ 还是没有容量限制

  • ❌ 没有LRU淘汰策略




四、版本3:完整的缓存实现(LRU+过期)⭐


import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class AdvancedCache<K, V> {

// 缓存容量
private final int maxSize;

// 存储:ConcurrentHashMap + LinkedHashMap(LRU)
private final ConcurrentHashMap<K, CacheEntry<V>> cache;

// 定时清理线程
private final ScheduledExecutorService cleanupExecutor;

// 统计信息
private final AtomicInteger hitCount = new AtomicInteger(0);
private final AtomicInteger missCount = new AtomicInteger(0);

// 缓存项
static class CacheEntry<V> {
final V value;
final long expireTime;
volatile long lastAccessTime; // 最后访问时间

CacheEntry(V value, long ttl) {
this.value = value;
this.expireTime = System.currentTimeMillis() + ttl;
this.lastAccessTime = System.currentTimeMillis();
}

boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}

void updateAccessTime() {
this.lastAccessTime = System.currentTimeMillis();
}
}

public AdvancedCache(int maxSize) {
this.maxSize = maxSize;
this.cache = new ConcurrentHashMap<>(maxSize);

this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(
new ThreadFactoryBuilder()
.setNameFormat("cache-cleanup-%d")
.setDaemon(true)
.build()
);

// 每秒清理过期数据
cleanupExecutor.scheduleAtFixedRate(
this::cleanup,
1, 1, TimeUnit.SECONDS
);
}

/**
* 存入缓存
*/

public void put(K key, V value, long ttlMillis) {
// 检查容量
if (cache.size() >= maxSize) {
evictLRU(); // LRU淘汰
}

cache.put(key, new CacheEntry<>(value, ttlMillis));
}

/**
* 获取缓存
*/

public V get(K key) {
CacheEntry<V> entry = cache.get(key);

if (entry == null) {
missCount.incrementAndGet();
return null;
}

// 检查过期
if (entry.isExpired()) {
cache.remove(key);
missCount.incrementAndGet();
return null;
}

// 更新访问时间
entry.updateAccessTime();
hitCount.incrementAndGet();

return entry.value;
}

/**
* 带回调的获取(类似Guava Cache)
*/

public V get(K key, Callable<V> loader, long ttlMillis) {
CacheEntry<V> entry = cache.get(key);

// 缓存命中且未过期
if (entry != null && !entry.isExpired()) {
entry.updateAccessTime();
hitCount.incrementAndGet();
return entry.value;
}

// 缓存未命中,加载数据
try {
V value = loader.call();
put(key, value, ttlMillis);
return value;
} catch (Exception e) {
throw new RuntimeException("加载数据失败", e);
}
}

/**
* LRU淘汰:移除最久未访问的
*/

private void evictLRU() {
K lruKey = null;
long oldestAccessTime = Long.MAX_VALUE;

// 找出最久未访问的key
for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) {
long accessTime = entry.getValue().lastAccessTime;
if (accessTime < oldestAccessTime) {
oldestAccessTime = accessTime;
lruKey = entry.getKey();
}
}

if (lruKey != null) {
cache.remove(lruKey);
}
}

/**
* 定时清理过期数据
*/

private void cleanup() {
cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

/**
* 获取缓存命中率
*/

public double getHitRate() {
int total = hitCount.get() + missCount.get();
return total == 0 ? 0 : (double) hitCount.get() / total;
}

/**
* 获取统计信息
*/

public String getStats() {
return String.format(
"缓存统计: 大小=%d, 命中=%d, 未命中=%d, 命中率=%.2f%%",
cache.size(),
hitCount.get(),
missCount.get(),
getHitRate() * 100
);
}

/**
* 关闭缓存
*/

public void shutdown() {
cleanupExecutor.shutdown();
cache.clear();
}
}



五、完整使用示例📝


public class CacheExample {

public static void main(String[] args) throws InterruptedException {

// 创建缓存:最大100个,5秒过期
AdvancedCache<String, User> cache = new AdvancedCache<>(100);

// 1. 基本使用
cache.put("user:1", new User("张三", 20), 5000);
User user = cache.get("user:1");
System.out.println("获取缓存: " + user);

// 2. 带回调的获取(自动加载)
User user2 = cache.get("user:2", () -> {
// 模拟从数据库加载
System.out.println("从数据库加载 user:2");
return new User("李四", 25);
}, 5000);
System.out.println("加载数据: " + user2);

// 3. 再次获取(命中缓存)
User cached = cache.get("user:2");
System.out.println("命中缓存: " + cached);

// 4. 等待过期
Thread.sleep(6000);
User expired = cache.get("user:1");
System.out.println("过期数据: " + expired); // null

// 5. 查看统计
System.out.println(cache.getStats());

// 6. 关闭缓存
cache.shutdown();
}
}

输出:


获取缓存: User{name='张三', age=20}
从数据库加载 user:2
加载数据: User{name='李四', age=25}
命中缓存: User{name='李四', age=25}
过期数据: null
缓存统计: 大小=1, 命中=2, 未命中=1, 命中率=66.67%



六、实战:用户Session缓存🔐


public class SessionCache {

private final AdvancedCache<String, UserSession> cache;

public SessionCache() {
this.cache = new AdvancedCache<>(10000); // 最大1万个session
}

/**
* 创建Session
*/

public String createSession(Long userId) {
String sessionId = UUID.randomUUID().toString();
UserSession session = new UserSession(userId, LocalDateTime.now());

// 30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);

return sessionId;
}

/**
* 获取Session
*/

public UserSession getSession(String sessionId) {
return cache.get(sessionId);
}

/**
* 刷新Session(延长过期时间)
*/

public void refreshSession(String sessionId) {
UserSession session = cache.get(sessionId);
if (session != null) {
// 重新设置30分钟过期
cache.put(sessionId, session, 30 * 60 * 1000);
}
}

/**
* 删除Session(登出)
*/

public void removeSession(String sessionId) {
cache.remove(sessionId);
}

static class UserSession {
final Long userId;
final LocalDateTime createTime;

UserSession(Long userId, LocalDateTime createTime) {
this.userId = userId;
this.createTime = createTime;
}
}
}



七、与Guava Cache对比📊


Guava Cache的使用


LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.maximumSize(1000) // 最大容量
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期
.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后过期
.recordStats() // 记录统计
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
return loadUserFromDB(key); // 加载数据
}
});

// 使用
User user = cache.get("user:1"); // 自动加载

功能对比


功能自定义CacheGuava Cache
线程安全
过期时间
LRU淘汰
自动加载
弱引用
统计信息
监听器
刷新

建议:



  • 简单场景:自定义实现

  • 生产环境:用Guava Cache或Caffeine




八、性能优化技巧⚡


技巧1:分段锁


public class SegmentedCache<K, V> {

private final int segments = 16;
private final AdvancedCache<K, V>[] caches;

@SuppressWarnings("unchecked")
public SegmentedCache(int totalSize) {
this.caches = new AdvancedCache[segments];
int sizePerSegment = totalSize / segments;

for (int i = 0; i < segments; i++) {
caches[i] = new AdvancedCache<>(sizePerSegment);
}
}

private AdvancedCache<K, V> getCache(K key) {
int hash = key.hashCode();
int index = (hash & Integer.MAX_VALUE) % segments;
return caches[index];
}

public void put(K key, V value, long ttl) {
getCache(key).put(key, value, ttl);
}

public V get(K key) {
return getCache(key).get(key);
}
}

技巧2:异步加载


public class AsyncCache<K, V> {

private final AdvancedCache<K, CompletableFuture<V>> cache;
private final ExecutorService loadExecutor;

public CompletableFuture<V> get(K key, Callable<V> loader, long ttl) {
return cache.get(key, () ->
CompletableFuture.supplyAsync(() -> {
try {
return loader.call();
} catch (Exception e) {
throw new CompletionException(e);
}
}, loadExecutor),
ttl
);
}
}



九、常见陷阱⚠️


陷阱1:缓存穿透


// ❌ 错误:不存在的key反复查询数据库
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId); // 每次都查数据库
if (user != null) {
cache.put(userId, user, 5000);
}
}
return user;
}

// ✅ 正确:缓存空对象
public User getUser(String userId) {
User user = cache.get(userId);
if (user == null) {
user = loadFromDB(userId);
// 即使是null也缓存,但设置短过期时间
cache.put(userId, user != null ? user : NULL_USER, 1000);
}
return user == NULL_USER ? null : user;
}

陷阱2:缓存雪崩


// ❌ 错误:所有key同时过期
for (String key : keys) {
cache.put(key, value, 5000); // 5秒后同时过期
}

// ✅ 正确:过期时间随机化
for (String key : keys) {
long ttl = 5000 + ThreadLocalRandom.current().nextInt(1000);
cache.put(key, value, ttl); // 5-6秒随机过期
}



十、面试高频问答💯


Q1: 如何保证缓存的线程安全?


A:



  • 使用ConcurrentHashMap

  • volatile保证可见性

  • CAS操作保证原子性


Q2: 如何实现过期淘汰?


A:



  • 惰性删除:访问时检查过期

  • 定时删除:定时任务扫描

  • 两者结合


Q3: 如何实现LRU?


A:



  • 记录访问时间

  • 容量满时淘汰最久未访问的


Q4: 缓存穿透/击穿/雪崩的区别?


A:



  • 穿透:查询不存在的key,缓存和DB都没有

  • 击穿:热点key过期,大量请求打到DB

  • 雪崩:大量key同时过期




十一、总结🎯


核心要点



  1. 线程安全:ConcurrentHashMap

  2. 过期策略:定时清理+惰性删除

  3. 容量限制:LRU淘汰

  4. 性能优化:分段锁、异步加载

  5. 监控统计:命中率、容量


生产建议



  • 简单场景:自己实现

  • 复杂场景:用Guava Cache

  • 极致性能:用Caffeine




下期预告: 为什么双重检查锁定(DCL)是错误的?指令重排序的陷阱!🔐


作者:用户6854537597769
来源:juejin.cn/post/7563511077180473386
收起阅读 »

Android文件下载完整性保证:快递员小明的故事

有趣的故事:快递员小明的包裹保卫战 想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外: 数据损坏:就像包裹被雨淋湿 网络中断:就像送货路上遇到施工 恶意篡改:就像包裹被坏人调包 小明如何确保客户收...
继续阅读 »

有趣的故事:快递员小明的包裹保卫战


想象一下,小明是个快递员,负责从仓库(服务器)运送包裹(文件)到客户(Android设备)。但路上有各种意外:



  • 数据损坏:就像包裹被雨淋湿

  • 网络中断:就像送货路上遇到施工

  • 恶意篡改:就像包裹被坏人调包


小明如何确保客户收到的包裹完好无损呢?


核心技术原理


1. 校验和验证(Checksum) - "包裹清单核对"


就像快递员对照清单检查物品数量:


// MD5校验 - 快速但安全性较低
public boolean verifyFileMD5(File file, String expectedMD5) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
md.update(buffer, 0, length);
}
byte[] digest = md.digest();

// 转换为十六进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("x", b));
}
String actualMD5 = sb.toString();

fis.close();
return actualMD5.equals(expectedMD5.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

2. SHA系列校验 - "高级防伪验证"


// SHA-256校验 - 更安全的选择
public boolean verifyFileSHA256(File file, String expectedSHA256) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
FileInputStream fis = new FileInputStream(file);
byte[] buffer = new byte[8192];
int length;
while ((length = fis.read(buffer)) != -1) {
digest.update(buffer, 0, length);
}
byte[] hash = digest.digest();

StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}

fis.close();
return hexString.toString().equals(expectedSHA256.toLowerCase());
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

3. 完整下载管理器实现


public class SecureDownloadManager {
private Context context;
private DownloadListener listener;

public interface DownloadListener {
void onDownloadProgress(int progress);
void onDownloadSuccess(File file);
void onDownloadFailed(String error);
void onIntegrityCheckFailed();
}

public SecureDownloadManager(Context context, DownloadListener listener) {
this.context = context;
this.listener = listener;
}

public void downloadFileWithVerification(String fileUrl,
String fileName,
String expectedHash,
HashType hashType)
{
new DownloadTask(fileUrl, fileName, expectedHash, hashType).execute();
}

private class DownloadTask extends AsyncTask<Void, Integer, Boolean> {
private String fileUrl;
private String fileName;
private String expectedHash;
private HashType hashType;
private File downloadedFile;

public DownloadTask(String fileUrl, String fileName,
String expectedHash, HashType hashType)
{
this.fileUrl = fileUrl;
this.fileName = fileName;
this.expectedHash = expectedHash;
this.hashType = hashType;
}

@Override
protected Boolean doInBackground(Void... voids) {
try {
// 创建目标文件
File downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
downloadedFile = new File(downloadsDir, fileName);

// 开始下载
URL url = new URL(fileUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.connect();

// 检查响应码
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
return false;
}

// 获取文件大小用于进度计算
int fileLength = connection.getContentLength();

// 下载文件
InputStream input = connection.getInputStream();
FileOutputStream output = new FileOutputStream(downloadedFile);

byte[] buffer = new byte[4096];
long total = 0;
int count;
while ((count = input.read(buffer)) != -1) {
// 如果用户取消了任务
if (isCancelled()) {
input.close();
output.close();
downloadedFile.delete();
return false;
}
total += count;

// 发布进度
if (fileLength > 0) {
publishProgress((int) (total * 100 / fileLength));
}

output.write(buffer, 0, count);
}

output.flush();
output.close();
input.close();

// 验证文件完整性
return verifyFileIntegrity(downloadedFile, expectedHash, hashType);

} catch (Exception e) {
e.printStackTrace();
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
return false;
}
}

@Override
protected void onProgressUpdate(Integer... values) {
if (listener != null) {
listener.onDownloadProgress(values[0]);
}
}

@Override
protected void onPostExecute(Boolean success) {
if (success) {
if (listener != null) {
listener.onDownloadSuccess(downloadedFile);
}
} else {
if (downloadedFile != null && downloadedFile.exists()) {
downloadedFile.delete();
}
if (listener != null) {
listener.onIntegrityCheckFailed();
}
}
}
}

private boolean verifyFileIntegrity(File file, String expectedHash, HashType hashType) {
try {
String actualHash;
switch (hashType) {
case MD5:
actualHash = calculateMD5(file);
break;
case SHA256:
actualHash = calculateSHA256(file);
break;
case SHA1:
actualHash = calculateSHA1(file);
break;
default:
actualHash = calculateSHA256(file);
}
return actualHash != null && actualHash.equalsIgnoreCase(expectedHash);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}

public enum HashType {
MD5, SHA1, SHA256
}
}

4. 使用示例


public class MainActivity extends AppCompatActivity {
private SecureDownloadManager downloadManager;
private ProgressBar progressBar;
private TextView statusText;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

progressBar = findViewById(R.id.progressBar);
statusText = findViewById(R.id.statusText);

downloadManager = new SecureDownloadManager(this, new SecureDownloadManager.DownloadListener() {
@Override
public void onDownloadProgress(int progress) {
runOnUiThread(() -> {
progressBar.setProgress(progress);
statusText.setText("下载中: " + progress + "%");
});
}

@Override
public void onDownloadSuccess(File file) {
runOnUiThread(() -> {
statusText.setText("下载完成且文件完整!");
Toast.makeText(MainActivity.this, "文件验证成功", Toast.LENGTH_SHORT).show();
});
}

@Override
public void onDownloadFailed(String error) {
runOnUiThread(() -> {
statusText.setText("下载失败: " + error);
});
}

@Override
public void onIntegrityCheckFailed() {
runOnUiThread(() -> {
statusText.setText("文件完整性验证失败!");
Toast.makeText(MainActivity.this, "文件可能已损坏", Toast.LENGTH_LONG).show();
});
}
});

// 开始下载
Button downloadBtn = findViewById(R.id.downloadBtn);
downloadBtn.setOnClickListener(v -> {
String fileUrl = "https://example.com/file.zip";
String expectedSHA256 = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234";

downloadManager.downloadFileWithVerification(
fileUrl,
"myfile.zip",
expectedSHA256,
SecureDownloadManager.HashType.SHA256
);
});
}
}

时序图:完整的下载验证流程


deepseek_mermaid_20251010_1d824c.png


关键要点总结



  1. 双重保障:下载完成 + 完整性验证 = 安全文件

  2. 进度反馈:让用户知道下载状态

  3. 自动清理:验证失败时自动删除损坏文件

  4. 灵活算法:支持多种哈希算法适应不同场景

  5. 异常处理:网络中断、文件损坏等情况的妥善处理


就像快递员小明不仅要把包裹送到,还要确保包裹完好无损一样,我们的下载管理器既要完成下载,又要保证文件的完整性!


作者:Android童话镇
来源:juejin.cn/post/7559190511824519187
收起阅读 »

面试官:手写一个深色模式切换过渡动画

web
在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切...
继续阅读 »

在开发Web应用时,深色模式已成为现代UI设计的标配功能。然而,许多项目在实现主题切换时仅简单改变CSS变量,缺乏平滑的过渡动画,导致用户体验突兀。作为开发者,我们常被期望在满足功能需求的同时,打造更精致的用户交互体验。面试中,被问及"如何实现流畅的深色模式切换动画"时,很多人可能只答出使用CSS transition,而忽略了现代浏览器的View Transitions API这一高级解决方案。


读完本文,你将掌握:



  1. 使用View Transitions API实现流畅的主题切换动画

  2. 理解深色模式切换的核心原理与实现细节

  3. 能够将这套方案应用到实际项目中,提升用户体验


image.png

前言


在实际项目中,深色模式切换几乎是前端的“标配”。常见做法是通过 classList.toggle("dark") 切换样式,再配合 transition 做淡入淡出。然而,这种效果在用户体验上略显生硬:颜色瞬间大面积切换,即便有渐变也会显得突兀。


随着 View Transitions API 的出现,我们可以给“页面状态切换”添加炫酷的过渡动画。今天就带大家实现一个 以点击位置为圆心、扩散切换主题的深色模式动画,读完本文你将收获:



  • 了解 document.startViewTransition 的工作原理

  • 学会用 clipPath + animate 控制圆形扩散动画




核心铺垫:我们需要解决什么问题?


在设计方案前,先明确 3 个核心目标:



  1. 流畅过渡:避免普通 transition 的“整体闪烁”,实现局部扩散过渡。

  2. 交互感强:以用户点击位置为动画圆心,符合直觉。

  3. 可扩展:方案可适配 Vue3 组件体系,不依赖复杂第三方库。


为此,我们需要用到几个关键技术点:



  • View Transitions API:提供 document.startViewTransition,可以对 DOM 状态切换设置过渡动画。

  • clip-path:通过 circle(r at x y) 定义动画圆形,从 0px 扩展到最大半径。

  • computeMaxRadius:计算从点击点到四角的最大距离,确保圆形覆盖全屏。

  • .animate:使用 document.documentElement.animate 精确控制过渡过程。


Math.hypot:计算平面上点到原点的距离


Math.hypot()是ES2017引入的一个JavaScript函数,用于计算所有参数平方和的平方根,即计算n维欧几里得空间中从原点到指定点的距离。


image.png

在深色模式切换动画中,我们使用它来计算覆盖整个屏幕的最大圆形半径:


斜边计算


Math.hypot(maxX, maxY):使用勾股定理计算从点击点到对角的距离


image.png


clip-path


recording.gif

clip-path是CSS属性,允许我们定义元素的可见区域,将其裁剪为基本形状或SVG路径。在深色模式切换动画中,我们用它创建从点击点向外扩散的圆形动画效果。


<basic-shape>一种形状,其大小和位置由 <geometry-box> 的值定义。如果没有指定 <geometry-box>,则将使用 border-box 用为参考框。取值可为以下值中的任意一个:



  • inset()


    定义一个 inset 矩形。


  • circle()


    定义一个圆形(使用一个半径和一个圆心位置)。


  • ellipse()


    定义一个椭圆(使用两个半径和一个圆心位置)。


  • polygon()


    定义一个多边形(使用一个 SVG 填充规则和一组顶点)。


  • path()


    定义一个任意形状(使用一个可选的 SVG 填充规则和一个 SVG 路径定义)。



这里使用circle()来实现效果


该函数接受以下参数:



  • 半径:定义圆形的大小(0px到计算的最大半径)

  • at关键词:分隔半径和中心点位置

  • 中心点位置:使用x y坐标指定圆形中心


startViewTransition:浏览器视图转换API


基本概念


document.startViewTransition()是View Transitions API的核心方法,它告诉浏览器DOM即将发生变化,并允许我们为这些变化创建平滑的过渡动画。


生命周期与关键事件



  1. 调用startViewTransition:浏览器准备开始视图转换

  2. 执行回调函数:DOM状态更新

  3. transition.ready事件:视图转换准备就绪,可以应用动画

  4. 视图转换完成:动画结束,新状态成为稳定状态


浏览器兼容性处理


在实际应用中,我们需要检查浏览器是否支持此API:


const isAppearanceTransition =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!isAppearanceTransition) {
// 不支持View Transitions API时的降级处理
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

这种处理确保在不支持新特性的浏览器中,功能仍然可用,只是没有动画效果。


核心实现:从逻辑到代码


graph TD

A[用户点击切换按钮] --> B{浏览器是否支持<br/>View Transitions API?}
B -- 否 --> C[直接切换主题变量<br/>无动画效果]
B -- 是 --> D[获取点击坐标X,Y]
D --> E[计算覆盖全屏的最大半径]
E --> F[启动视图转换]
F --> G[执行回调函数<br/>更新isDark状态]
G --> H[设置HTML的dark class<br/>更新CSS变量]
H --> I[等待DOM更新完成<br/>nextTick]
I --> J[视图转换准备就绪]
J --> K[应用clipPath动画<br/>从点击点向外扩散]
K --> L[动画完成<br/>主题切换完成]

style B fill:#f9f,stroke:#333,stroke-width:2px
style K fill:#9cf,stroke:#333,stroke-width:2px


  1. 用户交互:用户点击切换按钮,触发主题切换流程

  2. 浏览器兼容性检查:判断当前浏览器是否支持View Transitions API

  3. 降级处理:在不支持API的浏览器中直接切换主题

  4. 动画核心逻辑



    • 获取点击位置作为动画起点

    • 计算覆盖全屏的最大半径

    • 启动视图转换过程



  5. 状态更新:实际执行主题状态更新和CSS类设置

  6. 动画触发:在视图转换准备就绪后,应用clipPath动画效果

  7. 完成:动画结束,新主题状态稳定


步骤 1:封装主题切换


    function setupThemeClass(isDark) {
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
}

作用:控制 html.dark 类名,完成主题切换。




步骤 2:计算扩散最大半径


    function computeMaxRadius(x, y) {
const maxX = Math.max(x, window.innerWidth - x);
const maxY = Math.max(y, window.innerHeight - y);
return Math.hypot(maxX, maxY); // √(maxX² + maxY²)
}


作用:确保无论点击哪里,扩散圆都能覆盖屏幕。




步骤 3:触发 View Transition


    function onToggleClick(event) {
const isSupported =
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (!isSupported) {
// 回退方案:直接切换
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
return;
}

const x = event.clientX;
const y = event.clientY;
const endRadius = computeMaxRadius(x, y);

// 开启视图过渡
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
setupThemeClass(isDark.value);
await nextTick(); // 等 Vue DOM 更新
});

transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];

document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: "ease-in",
pseudoElement: isDark.value
? "::view-transition-old(root)"
: "::view-transition-new(root)",
}
);
});
}

要点:


*startViewTransition 接收一个回调函数,里面执行 DOM 更新(切换主题)。


*transition.ready.then(...) 可以在 DOM 更新后定义动画效果。


*clipPath 数组定义了从 小圆 → 大圆 的扩散过程。


*pseudoElement 控制是对 新视图 还是 旧视图 应用动画。




步骤 4:覆盖默认过渡样式



::view-transition-new(root),
::view-transition-old(root) {
animation: none;
mix-blend-mode: normal;
}

::view-transition-old(root) {
z-index: 1;
}

::view-transition-new(root) {
z-index: 2147483646;
}

html.dark::view-transition-old(root) {
z-index: 2147483646;
}

html.dark::view-transition-new(root) {
z-index: 1;
}

作用:取消默认动画,手动用 clipPath 控制。通过 z-index 确保层级正确,否则可能看到“旧页面覆盖新页面”的异常。




效果演示


recording.gif

运行后:



  • 点击切换按钮时,以点击点为圆心,圆形扩散覆盖全屏,主题在扩散动画过程中完成切换。

  • 若浏览器不支持 View Transitions API(如 Safari),则自动降级为普通切换,不影响使用。


完整demo





延伸与避坑



  1. 兼容性问题



    • View Transitions API 目前在 Chromium 内核浏览器(Chrome 111+、Edge)可用,Safari/Firefox 尚未支持。

    • 可加上 isSupported 判断,优雅降级。



  2. 性能优化



    • 动画时建议避免页面过多重绘(如大量图片加载),否则会掉帧。

    • clip-path 本身是 GPU 加速属性,性能较好。



  3. 扩展思路



    • 除了圆形扩散,还可以用 polygon() 实现“百叶窗切换”或“对角线切换”。

    • 可以结合 路由切换 做“页面级过渡动画”。






总结


本文我们用 Vue3 + Element Plus + View Transitions API 实现了一个点击扩散式的深色模式切换动画,核心要点:



  • startViewTransition:声明 DOM 状态切换的动画上下文。

  • clipPath + animate:控制过渡动画形状与过程。

  • computeMaxRadius:计算圆形覆盖全屏的半径。

  • 优雅降级:确保不支持 API 的浏览器仍能正常切换。


作者:张海潮
来源:juejin.cn/post/7546326670648328219
收起阅读 »

真正的乐观,是做好被裁员的准备 | 跳槽决策四步法

引言 进入社会后,除了结婚、买房这类重要的事情外,跳槽、选择工作是我们最重要的决策。 每次跳槽,都决定了未来一段时间你处于的行业、岗位、收入,在一定程度上影响你的生活方式。 可就是如此重要的事情,我过去几次换工作,做的都不是太好。 我或许会每天都刷招聘网站,可...
继续阅读 »

引言


进入社会后,除了结婚、买房这类重要的事情外,跳槽、选择工作是我们最重要的决策。


每次跳槽,都决定了未来一段时间你处于的行业、岗位、收入,在一定程度上影响你的生活方式。


可就是如此重要的事情,我过去几次换工作,做的都不是太好。


我或许会每天都刷招聘网站,可就算刷到了意向的职位,也迟迟不敢在软件上点下“发送简历”按钮,可能是怕准备不充分、怕行情不好、怕离开熟悉的环境……结果拖到最后某一刻,被动离开。


最近看了一本书叫《怎样决定大事》,里面提到了一些做决策的方法,我试着把这套理论用在跳槽上,聊聊怎么样做出最清醒的跳槽决策。


核心用十六个字可以概括:看清处境,把握时机,避免直觉,适应局面,下面正文开始。


看清处境


马云说过员工离职就两个原因:钱没到位,心委屈了。


但真正让人下定决心离职的,从来不是这么简单的二选一,而是一连串复杂又难以理清的现实。



  • 比如年底一到,领导又说你没达预期,绩效一如既往地一般;

  • 办公室政治让你无所适从,干着最多的活,背着最大的锅;

  • 甚至公司的方向都让你怀疑未来是否值得继续坚持。


这些都让你有离职的想法,但是很多小事也不是不能忍。工资算不上多吧,但也是符合市场水平的。繁琐的工作干着有点烦, 但起码已经轻车熟路。


如果你也在犹豫和纠结,首先要弄清楚你自己的处境,你需要有「情景意识」,情景意识分为三个层次


ChatGPT Image 2025年8月13日 16_12_25.png


第一层,了解已经发生了什么。


这里就是刚刚提到的,比如不涨薪、领导pua、工作对自己没有任何成长,这些是已经发生的事情。


第二层,了解为什么会发生这种情况。


这里你思考导致现状的原因,比如技术水平不足,领导并没有给你涨薪。也有可能是公司所处的行业发展停滞,公司大量裁员,导致你工作越来越累。也有可能是你的领导没有眼光,发现不了你的优秀。


但需要注意的是,你要分析两到三种可能性,不是一种,也不是十种。


为什么不是一种?因为如果你头脑中只有一种解释,一旦判断错了,你的努力可能就毫无意义,甚至走向错误的方向。


比如工作经验比较少的程序员在遇到工作瓶颈时,常常会下意识归因为“我是不是太菜了?”。


毕竟程序员天生有技术思维,认为技术可以解决所有问题,性能问题?优化代码。bug频发,重构核心逻辑。


但你以为的问题,不一定是问题的全部。


比如现实世界有很多种可能:你的领导根本没打算提拔你,无论你多努力;你所在的部门业务边缘化,再怎么出色也没有舞台;公司战略转向AI,传统技术深耕已经不再受重视……


为什么不是十种?因为你如果考虑的原因太多,你的大脑就会陷入“分析瘫痪”,最终你什么决定也做不了。你需要抓大放小,找准核心矛盾,忽略那些无关紧要事情。


理清发生了什么、为什么发生,我们才能看清——未来会发生什么。


第三层,据此预测接下来会发生什么。


预测未来可能发生的情况,有一个反人性的技巧,是主动思考最坏的结果。


举个例子,你的公司因为经营原因,已经经历了两轮大规模裁员了,幸运的是一直没有裁到你,领导也安慰你好几次:“放心,你很重要。”


你该因为自己没被裁而庆幸吗?事实上你必须做好最坏的打算,那就是你会出现在下一轮的裁员名单上。


你需要提前思考对应的策略,比如开始评估外面的机会,更新简历,提前做准备。那么即使最坏的情况出现,你也不会猝不及防、惊慌失措。


未来是有不确定性的,我们往往会回避思考可怕的结果,但这会让自己在最坏的事情发生时,带来更多的伤害。


就像现在AI快速发展,几年内随时都有可能替代绝大部分基础性岗位,甚至高级的程序员也会被替代,那么我们必须做好现有岗位随时被替代的准备。


真正的乐观,是认真思考最坏的结果后,发现自己扛得住。


把握时机


毕业后我在济南工作,由于工资略显寒酸,互联网发展火热,我便有了去北京工作的念头。


念头归念头,回到现实我就怂了。那时候我根本没有工作经验,异地找工作这件事对我也很陌生,我不知道自己能不能找到工作,更不知道面试都会问什么技术问题。


我一想到这些就感觉头脑一片空白,想准备却无从下手。于是,我的选择是靠打游戏麻痹自己,开始拖延。


拖延了差不多半年,最后因为频繁出差,冲动之下选择裸辞去了北京。由于没有充分的准备,也是历经一番波折。


回顾这段经历,因为离职这件事没有明确的截止时间,我陷入了两种极端:要么因为恐惧未知,反复拖延,最后什么也没做;要么因为短期情绪,冲动行动。


决策不只是决定做什么,还有决定什么时候做。


先说说怎么避免冲动,那就是在做出离职决定之前,你需要先问自己一个简单的问题: “我需要现在离职吗?”


如果答案是否定的,就不着急做出决策。


这是因为我们很容易陷入情绪当中。


ChatGPT Image 2025年8月13日 16_26_41.png


比如你给领导提的好几个建议都不被采纳,感觉收到了冷落;技术不如你的同事拿到了比你还好的绩效,或者项目突然增加导致频繁加班。


程序员一定都听过“不要裸辞”这个忠告,一开始我认为这是因为离职后你可能会以为没有收入,导致面试的心态越来越不稳。后来我觉着这个忠告最大的作用,就是避免我们陷入情绪当中,一上头选择裸辞。


就像我当时裸辞后去了北京,由于没有任何准备,投了半个多月简历,一共就接到4个面试,绝大部分投递的简历都是已读不回。


你可能会说我技术很强,面试准备的非常充分,那我是不是可以随时选择离开呢?


你的确会有更多的底气,但是招聘是有招聘旺季的,比如所谓的“金三银四、金九银十”,因为正好处于企业全年、半年总结,企业会根据未来的计划进行人力盘点,释放岗位。但过去这两个节点,比如十一月份到来年一月份,那就是企业的招聘淡季,甚至是裁员季,如果你十月份离职,极容易遇见投递的简历大部分都未读未回。


诸葛亮已经万事俱备,那也得等等东风。


但是,等一等不意味着你什么也不做,你需要积极收集和换工作相关的信息。


改简历、刷题就不说了,现在什么行业比较火热?招聘的要求比起几年前有什么变化?未来什么样得企业最有发展前景?如果离职找工作不顺利,财务状况有没有什么影响?


这些都需要大量信息,并且充满不确定性,所以你需要去主动收集和了解。


当然了,你也不能一直准备下去,就像刷算法、刷面试题这件事,准备的越久,就会陷入边际效应递减,你不可能把所有的知识都学会,对吧?


这时候你就需要给自己制定一个时间框架,比如专心准备3个月,这期间不去面试。3个月后无论准备的如何,都必须让自己开始投递简历面试,避免回避和拖延。


避免直觉


你可能已经了解过很多认知陷阱:确认偏误让我们只寻找支持自己观点的信息;可得性启发让我们高估容易想起的事件发生概率;首因效应让我们过度依赖最初信息。


我举几个找工作容易陷入的认知陷阱。


第一个是「投射偏差」,比如把过去跳槽必涨薪的经验,投射到现在和将来,忽视了市场环境的变化。


18年我去北京时,互联网发展依旧火热,大厂扩招、抢人,程序员跳槽涨薪50%、80%都不是什么难事,如果你在大数据、P2P火热的时候进入相关企业,薪资翻倍的例子屡见不鲜。


可后来随着互联网增速放缓,涨薪越来越难,疫情之后各类企业发展不顺,别说涨薪了,如果被裁员被动找工作,平薪、降薪也都是有可能的。


如果你还按老的认知来,发现怎么涨薪总是不如预期,自然是心理落差极大,如果因为这个拒绝了一些各方面都不错的offer,那就太可惜了。


第二个是「短期结果焦虑」,过于关注短期结果成败,忽略了长远目标和发展。


你做足了准备,兴致勃勃的开始投简历,一连投了十几家都没接到面试,好不容易接到几个面试,结果全都在一面就挂了。


也许你的简历有硬伤,也许是没有准备充分,这很正常,查缺补漏,继续前行就好。


但你不能陷入焦虑和自我怀疑:我履历太差了,好公司根本不会看我的简历;我能力太差了,大厂的面试我根本不可能过。


最可怕的情况就是,因为面试不顺利,仓促入职一家并不满意的公司。


ChatGPT Image 2025年8月13日 16_46_05.png


第三个是单一维度决策,面对offer选择时,我们有可能陷入单一维度决策,比如是否大厂,薪资是否足够高,这是我自己总结出来的。


假设你这时候已经拿到了好多个offer,你该选择哪家企业入职呢?你可能特别关注薪资情况,你强烈的倾向于最高薪资的那个offer。你特别在乎名气,于是选择市场上名气最大的那个。


事实证明只考虑一个因素肯定不行,薪资最高的那个可能工作时间长还996,时薪并不比别的offer高。你的确入职了名气最大的那个企业,但做的不是核心业务,绩效不行,技术也没有什么成长。


我之前写过一篇文章,里面介绍了一个简单公式。比如在职业发展中,我觉着几个比较重要的是行业前景、公司文化和具体岗位,薪资当然也是我们衡量的一个重要指标,但其他的因素我们只做参考,而不能作为决策的决定因素。


对于选择offer这件事,我们也可以借助这个思路,识别几个你认为最重要的核心因素进行打分,选择总分最高的那一个。


别考虑太多,也不能考虑太少,这样才能做出最佳决策。


适应局面


即使决策已经做出,一切也并没有结束,你需要持续评估和调整,不断适应新的局面。


而我们面对新局面的反应,在很多时候是有点慢的。


这里我不得不提到AI,我谈不上对AI有着多深的见解,但当今AI巨头的模型,都已经具备了“完成小块的复杂代码”的能力。


我看到网上的一个预测,不出两年,就可以训练出一个可以自我迭代、不断尝试的AI编程高手。


高级程序员,将是最早一批开始被替代的。


当然,被替代的不仅是程序员行业,绘画、设计、金融、编辑,都面临着这个局面。


我提到AI,就是想提醒大家,对于处在行业第一线的我们,对于AI的适应能力有多高?


适应能力强的人,已经逐步用AI去完成越来越多的工作。而适应能力差的人,甚至现在遇见问题还是习惯性的打开搜索引擎,一点一点的翻看别人的资料。


我刚毕业时,深钻技术对职业生涯只有好处,没有坏处。但现在的局面是,如果还一股脑的让自己陷入到源码里面,不如用好AI,解放自己。


面对技术变革,就算没有应用,也要强迫自己了解。


最可怕的就是认为一些变化都与自己无关。


说在最后


做重大决策,主要分四步:看清处境,把握时机,避免直觉,适应局面。


这四步并不只用于跳槽,职业转换、城市迁移、关系选择、生活方式改变,都可以依靠这个模型去思考和行动。


你或许觉着这太麻烦了,但想想我们花了多少时间在鸡毛蒜皮的小事上?可能网购一件物品,为了价格货比三家;吃午饭订外卖,在各种美食间反复纠结;早上为了选择穿什么衣服,不断尝试。


把时间浪费在这些上面,却在重要的决策上匆匆决定,岂不是本末倒置吗?


这是东东拿铁的第88篇原创文章,欢迎关注,喜欢请三连。


作者:东东拿铁
来源:juejin.cn/post/7538357382453657626
收起阅读 »

SwiftUI redraw 机制全景解读:从 @State 到 Diffing

iOS
为什么 UIKit 程序员总问“我的状态去哪了?”特性UIKitSwiftUI视图定义与生命周期视图为类(Class),生命周期明确,长期驻留内存视图为值类型(Struct),每次刷新生成新实例状态保存方式状态保存在视图对象内部Struct 销毁后,状态需由外...
继续阅读 »

为什么 UIKit 程序员总问“我的状态去哪了?”

特性UIKitSwiftUI
视图定义与生命周期视图为类(Class),生命周期明确,长期驻留内存视图为值类型(Struct),每次刷新生成新实例
状态保存方式状态保存在视图对象内部Struct 销毁后,状态需由外部系统(如 ObservableObject、@State 等)托管

SwiftUI 提供了一堆 Property Wrapper 来“假装”状态还在视图里,核心就是 @State

@State 到底做了什么?(4 步流水线)

SwiftUI 把一次刷新拆成 4 个微观阶段:

  1. Invalidation(打脏标)

    对用到的属性插 依赖旗标;值改变时插旗为 dirty。

  2. Recompute(重算 body)

    只重算脏旗波及的 body;没读到值的 State 直接跳过。

  3. Diffing(结构差异)

    旧的 View 树 vs 新的 View 树,找出最小集合。

  4. Redraw(GPU 提交)

    Core Animation 仅把真正改动的图层提交给 GPU。

Attribute 系统:给“视图模板”注水

struct DemoView: View {
// 1️⃣ 在视图首次出现时,SwiftUI 为其创建一个持久化的存储槽位
@State private var threshold: CGFloat = 50.0 // ← 生成一个 attribute

var body: some View {
VStack { // ← 生成一个 attribute
Button("改变") {
threshold = 41.24 // 2️⃣ 写入新值 -> 生成 Transaction
}
Text("当前阈值 \(threshold)") // 3️⃣ 读取值 -> 建立依赖
}
}
}
  • Transaction:同一“事件循环”里所有 State 变化打包成一次事务。
  • Cascade Flag:只要 threshold 被打脏,所有读过它的 attribute 都会被连锁打脏。
  • Rule:body 里没读到 = 不 recomputed。官方 Instrument 里会显示body(skipped)

身份稳定:为什么“同一个”视图才能保持 State

// ❌ 错误示范:切换分支时 struct 类型相同,但身份不同 -> State 丢失
struct MyMusic: View {
@State private var rockNRoll = false
var body: some View {
VStack {
if rockNRoll {
MusicBand(name: "The Rolling Stones") // 新身份
} else {
MusicBand(name: "The Beatles") // 另一个身份
}
}
}
}

// ✅ 正确姿势:保证身份稳定(使用相同视图,只改参数)
struct MyMusic: View {
@State private var rockNRoll = false
var body: some View {
MusicBand(name: rockNRoll ? "The Rolling Stones" : "The Beatles")
}
}

口诀:

“同一视图,不同入参” 用参数传值;

“不同视图” 用 if/else 就会换身份,State 清零。

Body 重算粒度实验

只写不读 → 跳过

struct MovieDetail: View {
let movie: Movie
@State private var favoriteMovies: [String] = []

var body: some View {
VStack {
Button("加收藏") {
favoriteMovies.append(movie.name) // 只写
}
}
}
}

Instrument 显示:MovieDetail.body [skipped]

读写 → 重算,但子视图可跳过

var body: some View {
VStack {
HStack { // 👈 重算,因为读 favoriteMovies
Text(movie.name)
Image(systemName: favoriteMovies.contains(movie.name) ? "star.fill" : "star")
}
Artwork() // 👈 没传参,不 recomputed
Synopsis()
Reviews()
}
}

经验:把“纯展示”拆成无参子视图就能躲过重算。

Equatable:手动告诉 SwiftUI“别算我”

struct FlightDetail: View, Equatable {
let flightNumber: Int
let isDelayed: Bool

// 自定义相等:只看航班号
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.flightNumber == rhs.flightNumber
}

var body: some View {
VStack {
Text("航班 \(flightNumber)")
Text(isDelayed ? "延误" : "准点")
}
}
}
  • 若 struct 全是 POD 类型(Int/Bool…),SwiftUI 会跳过你的 ==,直接按位比较。
  • 想让自定义相等生效:
    1. 包一层 EquatableView(content: FlightDetail(...))
    2. 或者 .equatable() 修饰符。

@Observable vs ObservableObject:从“对象级”到“属性级”

特性Combine(旧)Observation(新)
监听机制监听 objectWillChange 发布者监听具体属性的 KeyPath 变化
更新范围任意 @Published 属性修改触发整个 body 重算仅读取了被修改属性的 body 部分重算
属性包装器要求需通过 @StateObject/@ObservedObject 管理可观察对象可直接使用 @State var model = MyModel() 声明模型
@Observable
final class Phone {
var number = "13800138000"
var battery = 100
}

struct Detail: View {
let phone: Phone // 无需 ObservedObject

var body: some View {
Text("电池 \(phone.battery)") // 只当 battery 变才重算
}
}

扩展场景:把知识用到“极端”界面

  1. 万级实时股票列表
  • Model 用 @Observable 把 price 单独标记;
  • 行视图实现 Equatable 仅对比 symbol + price
  • 收到 WebSocket 推送时只改 price,其余字段不动 → 一行只重算自己。
  1. 复杂表单(100+ 输入框)
  • 把每个字段拆成独立子视图;
  • 用 @FocusState + @Observable FormModel,保证敲一个字只重算当前 TextField
  • 提交按钮用 .equatable() 锁定,输入过程不刷新。
  1. 大图轮播 + 陀螺仪
  • @State 保存偏移;
  • 用 TimelineView 按帧读陀螺仪,但把昂贵的图片解码放到后台 Task
  • 仅当图片索引变化才改 Image(source),避免每帧 diff 大图。

个人总结:从“魔法”到“可预测”

SwiftUI 的刷新机制看似黑盒,实则高度 可确定:

“谁依赖,谁重算;谁相等,谁跳过;谁不变,谁不绘。”

把它当成一个依赖追踪引擎而非“UI 库”,就能解释所有现象:

  • 状态放对位置(身份稳定);
  • 依赖剪到最细(读多少算多少);
  • 比较给到提示(Equatable/Observable);
  • 性能用 Instrument 量化(Effect Graph + Core Animation)。

掌握这四步,SwiftUI 不再是“玄学”,而是可推导、可度量、可优化的纯函数式渲染管道。

参考资料 & 工具


作者:unravel2025
来源:juejin.cn/post/7556247403710496820
收起阅读 »

为VSCode扩展开发量身打造的UI库 - vscode-elements

web
大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。我会在这里分享关于 独立开发、编程技术、思考感悟 等内容,欢迎关注。 技术群与交朋友请在个人网站联系我,网站 1️⃣:chensuiyi.me,网站 2️⃣:me.yicode.tech。 如果你...
继续阅读 »

大家好,我是农村程序员,独立开发者,行业观察员,前端之虎陈随易。我会在这里分享关于 独立开发编程技术思考感悟 等内容,欢迎关注。


技术群与交朋友请在个人网站联系我,网站 1️⃣:chensuiyi.me,网站 2️⃣:me.yicode.tech


如果你觉得本文有用,一键三连 (点赞评论转发),就是对我最大的支持~





最近抽空在做我的 VSCode 插件 fnMap (函数地图) 的重构工作。



项目结构主要分为 3 部分:



  1. src-extension 是扩展的核心能力部分 (相当于后端)。

  2. src-webview 是界面展示部分 (相当于前端)。

  3. src-wasm 是新添加的部分,使用国产编程语言 MoonBit 来写,主要功能就是提供性能优化与部分核心代码加密。



那么重构呢,我想把 UI 换一下,目前用的是 arco-design-vue,字节出品的一个 UI 框架。



本来蛮喜欢的,但根据最近的更新来看,官方主要做 React 版本去了,Vue 版本 4 个多月没动静了。


而且,VSCode 有主题功能,框架如果要适配主题,那得进行不少魔改微调。



于是,我上下求索,找到这样一个为 VSCode 量身打造的 UI 库 vscode-elements


这效果,与 VSCode 简直绝配。


开源地址在这:https://github.com/vscode-elements/elements


如果你也在开发 VSCode 扩展,不妨了解一下这个。



在我这目前看到的唯一的缺点呢,就是还没有 Vue 版本。



目前的主要版本是基于 Lit 这个框架开发的,也就是前端标准的 Web Components 技术。



我跟踪这个框架几个星期了,今天 vscode-elements 刚发布 v2.0 版本,是时候为我的 fnMap 提供一份力量了。


也欢迎大家来体验我的 fnMap 插件,8月份 (本月) 将会发布 v9.0 版本,在进化的路上,一路前进。


作者:前端之虎陈随易
来源:juejin.cn/post/7533807870188470311
收起阅读 »

Lambda 底层原理全解析

是否好奇过,这样一行代码,编译器背后做了什么? auto lambda = [](int x) { return x * 2; }; 本文将带你深入 Lambda 的底层 一、Lambda回顾 auto lambda = [](int x) { ret...
继续阅读 »

是否好奇过,这样一行代码,编译器背后做了什么?


auto lambda = [](int x) { return x * 2; };


本文将带你深入 Lambda 的底层


一、Lambda回顾


    auto lambda = [](int x) { return x + 1; };
int result = lambda(5);

lambda我们很熟悉,是一个对象。

完整语法:[捕获列表] (参数列表) mutable 异常说明->返回类型{函数体}

基本的用法就不说,说几个用的时候注意的点



  1. & 捕获要注意悬垂引用,不要让捕获的引用,被销毁了还在使用

  2. this指针捕获,引起的悬垂指针


class MyClass {
int value = 42;
public:
auto getLambda() {
return [this]() { return value; }; //捕获 this 指针
}
};

MyClass* obj = new MyClass();
auto lambda = obj->getLambda();
delete obj;
lambda(); //this 指针悬垂

C++17解决:*this捕获,直接拷贝整个对象


return [*this]() { return value; };  // 拷贝整个对象

3.每个lambda都是唯一的


auto l1 = []() { return 1; };
auto l2 = []() { return 1; };

// l1 和 l2 类型不同!
// typeid(l1) != typeid(l2)

4.转换为函数指针


// 不捕获变量→可以转换
auto l1 = [](int x) { return x + 1; };
int (*fp)(int) = l1;//正确

// 捕获变量→不能转换
int a = 10;
auto l2 = [a](int x) { return a + x; };
int (*fp2)(int) = l2; //编译错误

记住这句话:函数指针=纯粹的代码地址,你一旦有成员变量,operator()就会依赖对象状态(a),无法转换为函数指针,函数指针调用时,不知道a的值从哪里来。

简单来说:lambda本质是对象+代码,而函数指针只能表示纯代码

解决方式:function(可以直接存储Lambda对象)


5.混淆了[=] 和 [&]


class MyClass {
int value = 100;
public:
void test() {
auto lambda = [=]() { //看起来按值捕获
std::cout << value << std::endl;
};
//等价于 [this],捕获的是this指针
//等价于this->value
}
};

6.lambda递归


auto factorial = [](int n) {  //无法递归调用自己
return n <= 1 ? 1 : n * factorial(n - 1); // 错误:factorial 未定义
};

//正确做法:C++23显式对象参数
auto factorial = [](this auto self, int n) { // C++23
return n <= 1 ? 1 : n * self(n - 1);
};

7.移动捕获


void process(std::unique_ptr<int>&& ptr) {
auto lambda = [p = std::move(ptr)]() { //移动到 Lambda
std::cout << *p << std::endl;
};
//错误做法
//auto lambda = [&ptr]() { //捕获的是引用
//std::cout << *ptr << std::endl;
//可能导致ptr移动后lambda失效.
lambda();
}

二、Lambda 的本质


Lambda不是普通的函数,也不是普通的对象,它是一个重载了operator()的类对象。
现在来证明一下:代码如下


#include <iostream>

int main() {
auto lambda = [](int x) { return x * 2; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}

gdb证明:

image.png
观察到lambda是一个结构体,且大小为1字节

引申出几个问题



  1. 为什么这里是一个空的结构体?

  2. 为什么大小为1字节?

  3. 还没有证明他是一个重载了operator()的对象


问题1:为什么这里是一个空的结构体?


我们来按值捕获参数试试:


    int main() {
int y=2;
auto lambda = [=](int x) { return x * 2+y * 3; };
int result = lambda(5);
std::cout << result << std::endl;
return 0;
}

gdb:

image.png

哦,原来捕获对象会存在这个结构体中,同时我们发现大小为4字节,就为数据的大小。

那我们捕获引用试试呢?

image.png

同样也是引用数据类型,但是由于引用底层是存着对象的地址,所以它的大小为8字节,是一个指针的大小。

回到上面,为什么我们一开始的结构体什么数据都没有还是为1字节呢,C++规定了空类大小不为0,最小为1字节(保证每个对象都有唯一的地址)


总结:引用或按值捕获的数据被存在lambda对象内部


问题2:证明他是一个重载了operator()的对象


(1)gdb继续调试:
image.png
可以看到,确实是调用了一个operator()


(2)我们在用C++ Insights验证一下

访问:cppinsights.io/
可以查看编译器实际生成的完整类定义


image.png
关注到operator()后面是一个const,说明不可以修改捕获的变量,mutable加上后const消失,可自行验证


作者:原装加多宝
来源:juejin.cn/post/7564694382999994406
收起阅读 »

老黄预言成真!全球首个 AI 原生游戏引擎,一句话秒出 GTA 级神作

「【新智元导读】谁曾想,「AI 竟能实时生成」** GTA 级大作。刚刚,谷歌、英伟达等机构联手,震撼发布全球首款 AI 原生 UGC 游戏引擎——Mirage,没有预设关卡,一句话即生游戏,超长十分钟沉浸式体验。」** 全球首款 AI 原生 UGC 游戏引擎...
继续阅读 »


「【新智元导读】谁曾想,「AI 竟能实时生成」** GTA 级大作。刚刚,谷歌、英伟达等机构联手,震撼发布全球首款 AI 原生 UGC 游戏引擎——Mirage,没有预设关卡,一句话即生游戏,超长十分钟沉浸式体验。」**

全球首款 AI 原生 UGC 游戏引擎诞生了!


今天,谷歌、英伟达、微软等八大机构联手,一同祭出了这款实时 AI 游戏引擎——Mirage。



传送门:blog.dynamicslab.ai/


它不同于传统游戏引擎,而是玩家想象力的「放大器」,任何人可以随心所欲「造」游戏!


如今,团队直接上线了两个超燃的实时演示(试玩版本),一个是 GTA 风格的都市乱斗(Urban Chaos)。


游戏操作延用传统风格,你可以自由走动,移动视角并且奔跑。



另一个是极限竞速:地平线风格的海岸漂移(Coastal drift)。



AI 原生 UGC 游戏的神奇之处就在于,你可以任意改变游戏内容。


比如在 GTA 风格的都市乱斗中,可以通过 Enter 键调出「世界控制」面板后,选择「阴云密布大雨降至」。


游戏画面马上就从晴空万里变成了黑云压城。



比如,在上面的海岸漂移中,通过 Enter 键调出「世界控制」面板后,可以输入「进入沙漠」。



然后游戏画面就会从绿洲直接变成沙漠!简直比《头号玩家》还要一颗赛艇!



老黄曾预言,「用不了十年,我们就能看到游戏中每个像素都是由 AI 实时生成的」。



Mirage 的登场,不仅让这一愿景更近一步,也预示着未来的游戏产业将迎来巨变。



就连 Hassabis 也很看好 Veo 3 生成的视频游戏



「「动嘴」实时体验 GTA 大作」


「10 分钟超长游玩」


传统游戏中,城市布局、任务剧情,一般都是事先设定好的,体验终究有限。


Mirage 彻底打破了这一局限,让玩家在游戏过程中动态创造全新的体验。



AI 大神谢赛宁大赞:太酷了


现在,仅通过自然语言、键盘输入,或是游戏柄,玩家即可请求一条逃生的小象、生成一辆载具,或即时扩展的城市天际线。


举个栗子,来一场倾盆大雨,动动嘴皮子就成了。



再比如,玩超级马里奥时,一句「出现砖块」,Mirage 就会为你实时生成。



可以看到,游戏会立即给出响应,将生成的元素,无缝融合到正在进行的模拟中。


这个 AI 游戏世界不仅仅是可交互的,还能与玩家「共同演化」。



此前,初创公司 Decart 和 Etched 上线的实时神经游戏引擎 Oasis,实现了 20 帧 / 秒的零延迟交互。



这一次,Mirage 的强大能力,直接掀翻了天花板,究竟有多硬核?


· 16 FPS 流畅可玩:在标准清晰度下,享受实时交互体验。


· 动态用户生成内容(UGC):玩家可以用自然语言指令改变世界。


· 更长游戏时间:Mirage 可生成长达数分钟、且视觉效果连贯的交互式游戏体验。


· 云端串流:无需下载,随时随地享受即时跨平台游戏。


· 无限可玩性:每一次游戏,都是独一无二的体验。


· 多模态操控:支持文本、键盘及手柄输入。


从今天起,玩家可以彻底告别千篇一律的预设关卡,在 Mirage 中无限编辑、扩展,甚至从零打造全新的世界。


更令人惊艳的是,Mirage 的画面和操作感,直逼 GTA、Forza 风格的沉浸式体验,远超「我的世界」、「毁灭战士」那样简约化的风格。


它还能支持 5-10 分钟,甚至更长时间的持续游玩,打破了短短几秒的片刻体验。


不论是赛车游戏、角色扮演游戏,还是平台跳跃游戏,Mirage 都能一键生成。


接下来,一起看看 Mirage 如何重塑 UGC 2.0。



「UGC 2.0」


「生成式游戏崛起」


什么是生成式游戏?


就是游戏的未来并非由专家设计师设计,而是完全靠你的想象实时生成、实时游戏。


比如你输入「一辆黄色计程车突然从街角出现,出现在主角右边」。


然后游戏画面中就会实时「生成」一辆描述中的黄色计程车。



再比如输入「一辆灰色轿车从街道右边出现,并停在主角身边」,然后你就会发现游戏画面中「真的出现」一辆灰色轿车。


你甚至可以跳到轿车的车顶。



传统游戏,不论是休闲游戏还是开放世界的 3A 大作,都是预先创作好的。


城市布局、任务剧情都是事先设定好的,玩家的体验最终有限。


Mirage 打破了这一界限,通过自然语言和控制器,玩家可以如同开了「外挂」一般,可以随时随地按照想象扩展游戏。


比如请求一条逃生的小巷、或者生成一辆载具。


MIRAGE 可以生成各种类型的游戏——从竞速游戏到角色扮演游戏再到平台动作游戏。


你可以驾驶未来战机飞行于空旷的末日世界。


视频详情


这就是 AI 原生 UGC 2.0,在这里,你就是造物主。


在这里,任何人都可以通过简单的文本提示生成属于自己的游戏。


在这里,玩家可以在游戏过程中实时创造、进化并重塑游戏内容。


并且,每一次体验都是独特、动态且无需预先编写脚本的。



「背后技术」


「「世界模型」立大功」


Mirage 的核心是一个实时的交互式「世界模型」,基于 Transformer 和扩散模型完成训练。


该框架整合了多项关键创新,由此能够生成可控、高保真的视频游戏序列。



Mirage 的强大体验,是建立在训练基础之上。


最关键因素,便是从互联网上收集的大规模多样化的游戏数据,提供了足够的广度来捕捉各种游戏机制和风格。


为此,研究团队开发了一款专用的数据记录工具,用于捕捉高质量的人类录制的游戏互动。


这些精心整理的会话,通过细微且高保真的示例丰富了数据集,对于训练模型理解复杂的玩家行为和情境化游戏逻辑至关重要。


然后,收集和记录的数据随后会被输入到一个「垂直训练」流程中——这是一种专注于游戏领域的特定领域训练方法。


这使得模型能够深入内化游戏世界的规则、系统模式和交互动态。


最终,便得到了一个能够生成连贯、真实且灵活的游戏内容的模型,突破了传统预设游戏的限制。



「「交互式生成」与「实时控制」完美融合」


Mirage 将帧级提示词处理集成至其核心,重新定义了实时交互。


这使得玩家输入的键盘指令和自然语言提示词,都能在游戏过程中被即时解析。



对于玩家来说,通过云游戏实现了随处可玩:


· 动态输入系统:Mirage 以超低延迟处理玩家的输入(主要通过键盘和文本),实现近乎即时的响应。


· 实时输出:视觉更新通过一个全双工通信通道流回浏览器,输入与输出并行处理,从而消除延迟,确保流畅交互。



其底层技术架构是一套定制化的因果 Transformer 模型,并通过以下技术实现了增强。


其中包括,专门的视觉编码器、经过优化的位置编码、专为长时间交互序列而优化的结构。


值得一提的是,Mirage 继承了大模型和扩散模型的优势,支持生成连贯的高质量内容。


这里,研究团队对扩散模型组件采用了先进的蒸馏策略,以同时确保生成速度与内容质量。


玩家可随时通过自然语言重塑游戏环境,触发世界的即时更新。


由 KV 缓存支持的长上下文窗口,能够确保即便世界在实时演变,视觉效果也能保持连贯一致。



「八大顶尖机构联手」


「造出最强 AI 游戏引擎」


Mirage 由一支技术深厚且富有创造力的团队打造,团队成员包括了 AI 研究人员、工程师和设计师。


他们来自谷歌、英伟达、Amazon、SEGA、苹果、微软、卡内基梅隆大学和加州大学圣地亚哥分校。


通过 UGC 2.0,Mirage 致力于推动生成式世界模型的边界——每一次边界的拓展都将颠覆我们的想象力。



同时,生成式玩法不仅仅是一个功能,更是一种全新的媒介。


从报纸、广播到电视、手机,再到 3A 大作和 4K 视频,人类的媒介正在不断进化。


沃顿商学院 CS 教授 Ethan Mollick 实测后表示,Mirage 虽未完全到位,但已取得了进展。




还有网友被 Mirage 实时生成效果彻底惊艳了——这简直像 PS2!



Mirage 指向一个全新的未来,在那里游戏无需下载或者等待设计——它们将被想象、被提示、被生成,并被我们亲身体验。


一切才刚刚开始。


参考资料:


x.com/ZhitingHu/s…


blog.dynamicslab.ai/


作者:新智元
来源:juejin.cn/post/7522421979534835738
收起阅读 »

android ViewBinding

1. 它是什么 & 有啥用 编译期生成与每个布局一一对应的 XXXBinding 类,帮你类型安全地拿到 View 引用;没有反射、没有运行时开销。 仅做“找 View”,不包含表达式/双向绑定/观察者(那是 DataBinding 的职责)。 2...
继续阅读 »

1. 它是什么 & 有啥用



  • 编译期生成与每个布局一一对应的 XXXBinding 类,帮你类型安全地拿到 View 引用;没有反射、没有运行时开销。

  • 仅做“找 View”,不包含表达式/双向绑定/观察者(那是 DataBinding 的职责)。


2. 开启方式(Gradle)


android {
buildFeatures { viewBinding = true }
}


  • 应用/库模块都可开;想排除某些布局,给布局根元素加:


<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true" ... />

3. 生成类与命名规则



  • activity_main.xml → ActivityMainBinding

  • item_user_info.xml → ItemUserInfoBinding

  • 只为有 id 的 View生成字段;布局根通过 binding.root 访问。


4. 三大常用场景


4.1 Activity


class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.title.text = "Hello"
binding.button.setOnClickListener { /* ... */ }
}
}

4.2 Fragment(避免内存泄漏的标准写法)


class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!

override fun onCreateView(
inflater: LayoutInflater, container: ViewGr0up?, savedInstanceState: Bundle?
)
: View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.list.adapter = adapter
}

override fun onDestroyView() {
_binding = null // 关键:与 View 的生命周期对齐
}
}


只在 onCreateView ~ onDestroyView 之间使用 binding;不要持有到 Fragment 的字段里跨越 onDestroyView。



可选:更安全的委托


class ViewBindingDelegate<T: ViewBinding>(
val fragment: Fragment,
val binder: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null
override fun getValue(thisRef: Fragment, property: KProperty<*>): T =
binding ?: binder(thisRef.requireView()).also {
binding = it
thisRef.viewLifecycleOwner.lifecycle.addObserver(object: DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) { binding = null }
})
}
}
fun <T: ViewBinding> Fragment.viewBinding(binder: (View)->T) =
ViewBindingDelegate(this, binder)

// 用法:private val binding by viewBinding(FragmentHomeBinding::bind)

4.3 RecyclerView.ViewHolder


class UserVH(val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root)

override fun onCreateViewHolder(parent: ViewGr0up, viewType: Int): UserVH {
val inflater = LayoutInflater.from(parent.context)
return UserVH(ItemUserBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: UserVH, position: Int) {
val item = getItem(position)
holder.binding.name.text = item.name
}

5. inflate / bind 的三种入口



  • XXXBinding.inflate(layoutInflater):常用于 Activity。

  • XXXBinding.inflate(inflater, parent, attachToParent):用于列表/Fragment。



    • 根为 的布局:必须提供非空 parent,且 attachToParent=true。



  • XXXBinding.bind(view):当你已有一个 View(比如 Dialog#setContentView(view) 后)再创建 binding。


6. include / merge 的细节



  • include:给 一个 android:id,生成的字段类型直接是被包含布局的 Binding


<include
android:id="@+id/header"
layout="@layout/include_header"/>


  • 使用:


binding.header.title.text = "Title"


  • merge 根布局:不产生多余容器,使用:


val b = IncludeToolbarBinding.inflate(inflater, parent, /*attachToParent=*/true)
// 注意:merge 必须 attachToParent = true

7. Dialog / BottomSheet / AlertDialog


class EditDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val b = DialogEditBinding.inflate(layoutInflater)
return AlertDialog.Builder(requireContext())
.setView(b.root)
.setPositiveButton("OK") { _, _ -> /* read b.editText */ }
.create()
}
}

8. 与 DataBinding / Compose 的区别与选型



  • ViewBinding:只做找 View,最快、最轻,无 包裹、无表达式。推荐大多数传统 View 项目使用。

  • DataBinding:支持 @{} 表达式、@BindingAdapter、双向绑定 @={};复杂但强大,编译慢、心智成本高。

  • Compose:声明式 UI。新项目优先;老项目可“渐进式”在局部用 ComposeView。

  • Compose × ViewBinding 互操作:在 Compose 内直接用 AndroidViewBinding(依赖 ui-viewbinding):


@Composable
fun LegacyCard() {
AndroidViewBinding(factory = LegacyCardBinding::inflate) {
title.text = "Hello from ViewBinding"
}
}

9. 常见坑 & 排查



  1. Fragment 泄漏:忘记在 onDestroyView() 置空 _binding。—— 现象:导航返回/旋转后崩溃或持有旧 View。

  2. merge 布局用了 attachToParent=false:导致 IllegalStateException 或看不见 UI。

  3. 在 onCreate() 就用 Fragment 的 binding:此时 View 还没创建,应在 onViewCreated() 之后使用。

  4. 重复 inflate:同一布局多次 inflate 却多次 setContentView/addView,导致层级重复/点击穿透。

  5. 多模块命名冲突:不同模块同名布局会各自产生 Binding,不会冲突;若共享资源注意命名前缀。

  6. 列表里频繁创建 binding:放在 onCreateViewHolder,不要在 onBindViewHolder 重复 inflate。


10. 实战小抄(可直接套用)


(1)列表条目 ViewHolder 模板)


class MsgVH(val b: ItemMsgBinding) : RecyclerView.ViewHolder(b.root)
override fun onCreateViewHolder(p: ViewGr0up, vt: Int) =
MsgVH(ItemMsgBinding.inflate(LayoutInflater.from(p.context), p, false))
override fun onBindViewHolder(h: MsgVH, pos: Int) = with(h.b) {
title.text = getItem(pos).title
time.text = getItem(pos).time
}

(2)Fragment × ViewBinding × Lifecycle


override fun onViewCreated(v: View, s: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.ui.collect { ui -> binding.progress.isVisible = ui.loading }
}
}
}

(3)include 组合标题栏


<!-- layout: activity_main.xml -->
<LinearLayout ...>
<include
android:id="@+id/toolbar"
layout="@layout/include_toolbar"/>

<!-- page content -->
</LinearLayout>

binding.toolbar.title.text = "主页"
binding.toolbar.back.setOnClickListener { onBackPressedDispatcher.onBackPressed() }

作者:南北是北北
来源:juejin.cn/post/7561077821995630644
收起阅读 »

忍了一年多,我终于对i18n下手了

web
前言 大家好,我是奈德丽。 过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。...
继续阅读 »

前言


大家好,我是奈德丽。


过去一年,我主要参与国际机票业务的开发工作,因此每天都要和多语言(i18n)打交道。熟悉我的朋友都知道,我这个人比较“惜力”(并不是,实际上只是忍不下去了),对于重复笨拙的工作非常抵触,于是,我开始思考如何优化团队的多语言管理模式。


痛点背景


先说说我们在机票项目中遇到的困境。


目前机票项目分为 H5 和 PC 两端,团队在维护多语言时主要通过在线 Excel进行管理:



  • 一个 Excel 文件,H5 和 PC 各自占一个 sheet 页;

  • 每次更新语言,需要先导出 Excel,然后手动跑脚本生成语言文件,再拷贝到项目中。


听起来还算凑合,但随着项目规模的扩大,问题逐渐显现:



  1. Key 命名混乱



    • 有的首字母大写,有的小驼峰、大驼峰混用;

    • 没有统一规则,难以模块化管理。



  2. 不支持模块化



    • 目前已有数千条 key

    • 查找、修改、维护都非常痛苦。



  3. 更新流程繁琐



    • 需要手动进入脚本目录,用 node 跑脚本;

    • 生成后再手动复制到项目中。




下面是一个实际的 Excel 片段,可以感受一下当时的混乱程度:


image.png


用原node脚本生成的语言文件如图


image.png


在这样的场景下,每次迭代多语言文件更新都像噩梦一样

尤其是我们很多翻译是通过AI 机翻生成,后续频繁修改的成本极高。


然而,机票项目的代码量太大、历史包袱太重,短期内几乎不可能彻底改造


image.png


新项目,新机会


机票项目虽然不能动,但在我们启动酒店业务新项目时,我决定不能再重蹈覆辙。

因此,在酒店项目中,我从零搭建了这套更高效的 i18n 管理方案。


目标很简单:



  1. 统一 key 规则,支持模块化,模块与内容间用.隔开,内容之间用下划线隔开;

  2. 自动化生成多语言 JSON 文件,集成到项目内,不再需要查找转化脚本的位置;

  3. 一条命令搞定更新,不需要手动拷贝。


于是,我在项目中新增了一个 scripts 目录,并编写了一个 excel-to-json.js 脚本。

package.json 中添加如下命令:


{
"scripts": {
"i18n:excel-to-json": "node scripts/excel-to-json.js"
}
}

以后,只需要运行下面一行命令,就能完成所有工作:


pnpm i18n:excel-to-json

再也不用手动寻找脚本路径,也不用手动复制粘贴,效率直接起飞 🚀


脚本实现


核心逻辑就是:

从 Excel 读取内容 → 转换为 JSON → 输出到项目 i18n 目录


完整代码如下:


import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import XLSX from 'xlsx'

/**
* 语言映射表:Excel 表头 -> 标准语言码
*/

const languageMap = {
'English': 'en',
'简中': 'zh-CN',
'Chinese (Traditional)': 'zh-TW',
'Korean': 'ko',
'Spanish': 'es',
'German Edited': 'de',
'Italian': 'it',
'Norwegian': 'no',
'French': 'fr',
'Arabic': 'ar',
'Thailandese': 'th',
'Malay': 'ms',
}

// 读取 Excel 文件
function readExcel(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`❌ Excel 文件未找到: ${filePath}`)
}
const workbook = XLSX.readFile(filePath)
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet)
}

/**
* 清空输出目录
*/

function clearOutputDir(dirPath) {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach(file => fs.unlinkSync(path.join(dirPath, file)))
console.log(`🧹 已清空目录: ${dirPath}`)
} else {
fs.mkdirSync(dirPath, { recursive: true })
console.log(`📂 创建目录: ${dirPath}`)
}
}

/**
* 生成 JSON 文件
*/

function generateLocales(rows, outputDir) {
const locales = {}

rows.forEach(row => {
const key = row.Key
if (!key) return

// 遍历语言列
Object.entries(languageMap).forEach(([columnName, langCode]) => {
if (!locales[langCode]) locales[langCode] = {}

const value = row[columnName] || ''
const keys = key.split('.')
let current = locales[langCode]

keys.forEach((k, idx) => {
if (idx === keys.length - 1) {
current[k] = value
} else {
current[k] = current[k] || {}
current = current[k]
}
})
})
})

// 输出文件
Object.entries(locales).forEach(([lang, data]) => {
const filePath = path.join(outputDir, `${lang}.json`)
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
console.log(`✅ 生成文件: ${filePath}`)
})
}

/**
* 检测缺失翻译
*/

function detectMissingTranslations(rows) {
const missing = []
rows.forEach(row => {
const key = row.Key
if (!key) return

Object.entries(languageMap).forEach(([columnName, langCode]) => {
const value = row[columnName]
if (!value?.trim()) {
missing.push({ key, lang: langCode })
}
})
})
return missing
}

function logMissingTranslations(missingList) {
if (missingList.length === 0) {
console.log('\n🎉 所有 key 的翻译完整!')
return
}

console.warn('\n⚠️ 以下 key 缺少翻译:')
missingList.forEach(item => {
console.warn(` - key: "${item.key}" 缺少语言: ${item.lang}`)
})
}

function main() {
const desktopPath = path.join(os.homedir(), 'Desktop', 'hotel多语言.xlsx')
const outputDir = path.resolve('src/i18n/locales')

const rows = readExcel(desktopPath)
clearOutputDir(outputDir)
generateLocales(rows, outputDir)
logMissingTranslations(detectMissingTranslations(rows))
}

main()

成果展示


这是在线语言原文档


image.png


这是生成后的多语言文件和内容
image.png


现在的工作流大幅简化:


操作旧流程新流程
运行脚本手动找脚本路径pnpm i18n:excel-to-json
文件生成位置生成后手动拷贝自动输出到项目
检测缺失翻译自动提示
key 命名管理无统一规则模块化、规范化

这套机制目前在酒店项目中运行良好,团队反馈也很积极。


总结


这次改造让我最大的感触是:



旧项目难以推翻重来,但新项目一定要趁早做好架构设计。



通过这次优化,我们不仅解决了多语言维护的痛点,还提升了团队整体开发效率。

而这套方案在未来如果机票项目有机会重构,也可以直接平滑迁移过去。


作者:奈德丽
来源:juejin.cn/post/7553105607417053194
收起阅读 »

聊聊我们公司的AI应用工程师每天都干啥?

过去两年间,随着我们团队落地和升级的AI产品越来越多,团队中逐渐出现了专门负责AI应用的工程师。 时间一长这些AI应用工程师们也分出了个三六九等,甚至有一些AI应用工程师因为思路无法转变,又退回到了普通工程师的岗位,不再负责AI应用。 今天这篇文章,给大家聊聊...
继续阅读 »

过去两年间,随着我们团队落地和升级的AI产品越来越多,团队中逐渐出现了专门负责AI应用的工程师。


时间一长这些AI应用工程师们也分出了个三六九等,甚至有一些AI应用工程师因为思路无法转变,又退回到了普通工程师的岗位,不再负责AI应用。


今天这篇文章,给大家聊聊,AI应用工程师每天都在干点啥? 优秀的AI应用工程师到底优秀在哪里?


AI应用工程师的生态位


AI应用工程师是处在只会大模型API调用和大模型算法工程师之间的一个生态位,目前还没有一个非常完善的岗位职能,不同的企业对于AI应用工程师的要求也有所不同。


我们这边之所以会需要AI应用工程师这样一个岗位,主要原因是我们要做AI产品的落地,和原有产品的AI化升级,在程序实现时会需要大量的大模型能力和一些特定的AI应用落地方案。


这时,团队中就需要有人对大模型的各项落地方案有所了解,能够配合产品在合适的节点设计合适的方案进行升级,负责将大模型能力转化为实际可用的产品功能


所以AI应用工程师不能只会简单的API调用,同时也不需要去了解太多的大模型底层技术,甚至Python基础都可以不需要(虽然会一些python有好处,但是这不是决定性的能力)。


目前在我们的团队中,他们主要负责:



  • 利用代码实现Agent或者workflow的流程

  • 实现具体的AI应用落地方案,联网搜索、RAG、微调等

  • 与提示词工程师对接完成大模型能力的接入

  • 与原有的程序进行结合

  • 极简版的流程验证逻辑实现

  • 复杂RPA + AI的落地


在其他企业的团队中,对AI应用工程师的要求还有:



  • AI工程系统设计,配合产品制定Agent流程

  • 编写项目中使用到的提示词

  • coze、dify的搭建

  • 等等


虽然说像搭建coze、写提示词这类工作,我并不认为这些工作应该是AI应用工程师工作,但是当下这个岗位的职责还没有固定,所以接下来我还是会把这些内容写进应具备的技能中。


大家全都了解一下,以备不时之需。


AI应用工程师需要具备的技能


要完成以上这些职责,AI应用工程师自然也要具备相应的技能, 但是对于Python基础、Pytorch框架、机器学习与深度学习的技术点,要不要进行学习呢?


不鼓励花大量时间学习,但是建议了解


不鼓励学习是因为:你学了你未必也用的到,我带团队在AI领域,TOC、TOB、新产品、新功能上线了不少了,没有用到python语言,并且也没看到python的必须性。当然了,你的团队主要语言是python,那你肯定是应该学的。


建议了解是因为:你毕竟是在做AI相关的内容,有相应的基础知识和技能点,的确在某些时候能带给你一些清晰的思路或者节约一些时间。


反过来再考虑你自己:深入Python基础、Pytorch框架、机器学习与深度学习这些技术点,你想做什么? 当你去深入学习这些的时候,你的目标不应该是AI应用工程师,而是人工智能算法相关的岗位。


学会了你就会不甘心,想去做更高级的岗位,但是那些岗位对学历、经验的要求不是一个半路出家自学能胜任的。


学不会你就是在浪费自己宝贵的生命和本次AI变革中的机会。


所以啊,认准自己的目标,别让自己难做。


那应该掌握的技能是哪些呢? 我们一条一条来说


利用代码实现Agent或者workflow的流程


需要了解Agent和workflow的区别,能够掌握是程序实现Agent和workflow的能力。


简单来说Agent和workflow的区别就是:



  • workflow是通过预定义的代码路径协调 LLM 和工具的系统。人类可以在其中的某些节点进行人为的干预。

  • Agent是 LLM 动态指导其自身流程和工具使用情况的系统,从而保持对其完成任务的方式的控制。完全有LLM主导,人类无法干预。


在程序上的实现区别:


Agent实际上最核心的代码只有九行, Agent所谓的动态指导其实就是一个while(true)


async function loop(llm) {
let msg = await userInput();
while (true) {
const { output, tool_calls } = await llm(msg);
if (tool_calls && tool_calls.length > 0) {
msg = await Promise.all(tool_calls.map(tc => handleToolCall(tc)));
} else {
msg = await userInput();
}
}
}

workflow的核心代码流程是提前写好的逻辑流程。


async function main(){
// 流程1:例如上下文处理
const query = await handleContext()
// 流程2:例如RAG
await handleRAG(query)
// 流程3:例如Function call
await handleFC(query)
// 流程4:例如调用API
await handleAPI()
}


实现具体的AI应用落地方案


在流程实现中,AI应用工程师需要把用到的技术点都做好,例如:联网搜索、RAG、微调等


联网搜索:当我们的产品需要用的联网搜索的时候,我们有两个选择:



  1. 用云端的联网搜索能力,缺点就是收费并且可控性不强,优点是方便省事。

  2. 自己实现联网搜索能力,优点是可以按照自己的需求指定搜索引擎、检索网站等。缺点就是需要自己编写代码。


RAG:当我们需要用RAG的时候,AI应用工程师应该做的:



  1. 实现RAG的完整流程

  2. 告知数据同事,需要怎样的数据,切片、QA、等

  3. 测试并保证RAG的召回率和准确率

  4. 对RAG的产出结果负责


微调:当我们需要用到微调的时候,AI应用工程师应该做的:



  1. 知道要微调什么样的任务,然后协调数据同事去准备相关的数据,并告知准备多少数据量、数据结构是怎样的、内容分布是怎样的

  2. 拿到数据后选择微调模型、微调平台,进行微调的工作

  3. 对微调结果进行测评,最终得到满意的结果

  4. 部署并调用模型


与提示词工程师对接完成大模型能力的接入


这一点不同的企业要求不一样,我们团队是专门培养的提示词工程师,有的团队是需要AI应用工程师来进行提示词的编写和调优


不过无论是不是AI应用工程师来编写提示词,他们都需要了解提示词工程,否则就没办法和提示词工程师进行有效沟通。


AI应用工程师需要与提示词工程师就当前节点提示词的输入、输出的结构和内容进行确定。


AI应用工程师保证输入的准确性,提示词工程师来保证输出的概率。


为什么是概率呢? 众所周知,提示词是不会百分百保证效果的。所以优秀的AI应用工程师在编写程序时会具备这一点的考虑:


例如下面这个例子:


提示词是用来判定当前输入的评论内容是否表达了善意,返回N或者Y。


也就是说这个提示词提示词的输入是评论内容,输出是N或者Y


请问:AI应用工程师要怎么对输出的Y或者N进行判断?


if(res === 'Y'),这样么?


不,他们写if(res.includes('Y'))


这里用全等就没有考虑到提示词输出的不确定性,所以有经验的工程师会在这里使用includes


极简版的流程验证逻辑实现


当下的阶段,通常在产品初期设计的流程都不会是最终生成环境的流程,因为在产品处理考虑的一些节点可能不全面,也因为需求会变动。


所以当我们在正式开始编写代码之前,都需要有一个极简版的流程实现,来验证我们的逻辑


有的团队使用coze一类的Agent搭建流程来实现这个验证逻辑, 但是对于一些复杂的流程,coze之类的产品就无能为力了


这时候,就需要AI应用工程师用代码快速实现一个简单流程来验证逻辑。


复杂RPA + AI的落地


随着AI能力的提升,目前最新的思路有RPA + AI来实现近乎全自动的部分工作,这就需要有专门的搭建这套流程的工程师。


这需要了解RPA的能力和AI能力,并且了解如何结合。


这个其实并不应该交给AI应用工程师来做,更适合专门的RPA工程师。


这个看发展吧。


AI工程系统设计,配合产品制定Agent流程


AI工程的系统设计,有些团队会要求AI应用工程师来做。


AI工程的系统设计,也就是应用的Agent或者workflow的设计,这一步是在产品出原型之前,就要设计好。


所以想要设计这部分内容,需要有两个能力:懂业务、懂技术。


这一点还是很难的,能够胜任的AI应用工程师,通常已经不是单纯意义上的工程师了。


给大家补一个当下AI产品落地的流程图:


lct.png

coze、dify的搭建


coze和dify的搭建,也是部分团队要求的任务,这一点会要求大家按照要求搭建智能体。


这个我这边就不细说了,网上到处都是搭建教程。


结语


AI应用工程师和普通工程师比起来,上下游关系人多了两个提示词工程师数据人员


提示词工程师需要和AI应用工程师进行沟通,关于提示词的输入、输出。


AI工程师需要了解当前节点的提示词是做什么的,有什么用? 就像了解原型中某个功能节点是做什么的一样。


就是说,AI应用工程师虽然只是做整个产品中的一部分工作,但仍然要了解全景的信息


这也是优秀的工程师优秀的地方,他们不仅能做好自己的工作,还能配合上下游关系人一起,让整个产品实现的更好。


哦对了,最后说一下:


AI应用工程师是一个新的岗位,并没有替代传统工程师,而是在传统工程能力基础上增加了新的维度。


加油!共勉!



☺️你好,我是华洛,如果你对程序员转型AI产品负责人感兴趣,请给我点个赞。


你可以在这里联系我👉http://www.yuque.com/hualuo-fztn…


已入驻公众号【华洛AI转型纪实】,欢迎大家围观,后续会分享大量最近三年来的经验和踩过的坑。



专栏文章


# 从0到1打造企业级AI售前机器人——实战指南三:RAG工程的超级优化


# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐


# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐


# 聊一下MCP,希望能让各位清醒一点吧🧐


# 实战派!百万PV的AI产品如何搭建RAG系统?


# 团队落地AI产品的全流程


# 5000字长文,AI时代下程序员的巨大优势!


作者:华洛
来源:juejin.cn/post/7512332419203727371
收起阅读 »

自定义 View 的 “快递失踪案”:为啥 invalidate () 喊不动 onDraw ()?

讲了个 “快递站送货” 的故事 —— 毕竟 View 的绘制流程,本质就是一场 “指令上报→调度→执行” 的快递游戏。 一、先搞懂:正常情况下,“快递” 是怎么送到的? 我们先把 View 体系比作一个城市快递网络: 你写的自定义View = 小区里的 “快...
继续阅读 »

讲了个 “快递站送货” 的故事 —— 毕竟 View 的绘制流程,本质就是一场 “指令上报→调度→执行” 的快递游戏。


一、先搞懂:正常情况下,“快递” 是怎么送到的?


我们先把 View 体系比作一个城市快递网络



  • 你写的自定义View = 小区里的 “快递站”(负责接收指令、安排送货);

  • invalidate() = 你给快递站打 “要送货” 的电话(请求重绘);

  • onDraw() = 快递站的 “送货员”(实际执行绘制逻辑);

  • ViewGr0up(父容器)= “区域调度中心”(转发快递站的请求);

  • ViewRootImpl = 快递总公司(连接快递站和 “城市交通系统”——Android 的 UI 线程);

  • Choreographer = 总公司的 “帧调度室”(负责安排每帧的工作,避免堵车)。


正常送货的时序图(代码 + 流程)


先看一段 “正常能收到货” 的自定义 View 代码:


// 小区快递站(自定义View)
public class NormalCustomView extends View {
private Paint mPaint;

public NormalCustomView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.RED);
mPaint.setTextSize(50);
}

// 送货员(执行绘制)
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员onDraw出发!画个文字");
canvas.drawText("快递送到啦~", 100, 100, mPaint);
}
}

// 你(开发者)打电话下单
NormalCustomView view = new NormalCustomView(this);
view.invalidate(); // 打“要送货”的电话

这通电话后,“快递” 会按以下流程送到(时序图用文字拆解):


exported_image.png


你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)你送货员送货员(onDraw)帧调度室帧调度室(Choreographer)快递总公司快递总公司(ViewRootImpl)区域调度中心区域调度中心(ViewGr0up)快递站快递站(CustomView)你(开发者)打call:invalidate()1. 检查自身状态(门开了吗?有货要送吗?)2. 上报:“我要送货,帮我转总公司!”3. 层层转发:“总公司,有个快递站要送货!”4. 申请排期:“下一帧给这个快递站留个位置!”5. 下一帧到了:“可以开始送货流程了!”6. 下达指令:“执行draw(),让送货员出发!”7. 派单:“onDraw,去把货(绘制)送了!”8. 完成:log打印“送货员onDraw出发!”


二、“快递失踪” 的 6 种常见原因(故事 + 代码 + 解决方案)


小明的问题,本质是 “快递在某个环节卡住了”。我们一个个拆穿这些 “卡壳点”—— 每个原因都对应故事里的场景,再给代码验证。


原因 1:快递站 “没开门”(View 不可见)


故事场景:小明早上给快递站打电话,站长接了说:“兄弟,我们还没开门(visibility=GONE),货送不了,挂了啊!”


原理:View 在收到invalidate()后,会先检查visibility属性:



  • 只有visibility == View.VISIBLE时,才会继续上报请求;

  • 如果是GONE(完全隐藏,不占空间)或INVISIBLE(隐藏但占空间),直接 “挂电话”,不触发后续流程。


代码验证(坑)


public class ClosedStationView extends View {
public ClosedStationView(Context context) {
super(context);
// 坑:设置为GONE,快递站没开门
setVisibility(View.GONE);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 永远不会打印
}
}

// 你打电话,但快递站没开门
ClosedStationView view = new ClosedStationView(this);
view.invalidate(); // 白打!

解决方案:确保visibilityView.VISIBLE(代码里setVisibility(View.VISIBLE),或 XML 里android:visibility="visible")。


原因 2:快递站 “没地方放货”(宽高为 0)


故事场景:小明这次确认快递站开了门,但站长说:“我们仓库是 0 平米(宽高 = 0),货没地方放,送不了!”


原理:View 绘制需要 “有空间”——getMeasuredWidth()getMeasuredHeight()必须都大于 0。如果宽高为 0,即使invalidate(),也会跳过后续流程(总不能在 “空气” 里画画吧)。


代码验证(坑)


<!-- XML里坑:宽高设为0 -->
<com.example.MyView
android:layout_width="0dp"
android:layout_height="0dp" />


public class ZeroSizeView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印,因为宽高0
}
}

解决方案



  • 检查 XML 的layout_width/layout_height(别设 0dp);

  • 代码里避免setLayoutParams(new LayoutParams(0, 0))

  • 重写onMeasure()时,确保setMeasuredDimension(width, height)的宽高大于 0。


原因 3:快递站 “只中转不送货”(ViewGr0up 默认不绘制)


故事场景:小明找的是 “区域调度中心”(ViewGr0up)当快递站,结果调度中心说:“我们只负责转发子快递站的货,自己不送货(willNotDraw=true)!”


原理ViewGr0up的默认值willNotDraw = true,意思是 “我是容器,只管子 View 的布局,自己不用绘制”。所以即使你给ViewGr0up调用invalidate(),它也会跳过onDraw()


代码验证(坑)


// 区域调度中心(ViewGr0up),默认不送货
public class NoDrawViewGr0up extends ViewGr0up {
public NoDrawViewGr0up(Context context) {
super(context);
// 坑:没改willNotDraw,默认true
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("调度中心", "自己送货!"); // 不打印
}
}

// 你给调度中心打电话
NoDrawViewGr0up group = new NoDrawViewGr0up(this);
group.invalidate(); // 白打!

解决方案:在ViewGr0up的构造里加一句setWillNotDraw(false),告诉它 “我也要自己送货(绘制)”:


public NoDrawViewGr0up(Context context) {
super(context);
setWillNotDraw(false); // 打开“自己绘制”开关
}

原因 4:你 “打错电话”(非 UI 线程调用 invalidate ())


故事场景:小明在外地出差,用 “公用电话”(非 UI 线程)给快递站打电话,结果电话直接被总公司拦截:“非本人手机(UI 线程),不接!”


原理:Android 的 View 体系是线程不安全的,只有创建 View 的 “UI 线程(主线程)” 才能调用invalidate()。非 UI 线程调用会:



  • 要么直接抛异常(Only the original thread that created a view hierarchy can touch its views);

  • 要么 “悄悄失败”(没抛异常但不触发onDraw())。


代码验证(坑)


public class WrongThreadView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!"); // 不打印
}
}

// 你用“公用电话”(非UI线程)打电话
WrongThreadView view = new WrongThreadView(this);
new Thread(() -> {
view.invalidate(); // 非UI线程!要么抛异常,要么白打
}).start();

解决方案:确保在 UI 线程调用invalidate(),常用方式:



  • view.post(Runnable)view.post(() -> view.invalidate())

  • Handler发消息到主线程;

  • ActivityrunOnUiThread(Runnable)里调用。


原因 5:区域调度中心 “拦截了请求”(父 View 阻断上报)


故事场景:小明的快递站属于 “郊区调度中心”,调度中心跟总公司关系不好,收到快递站的请求后,直接扔了:“不给你转总公司,爱咋咋地!”


原理:View 的invalidate()需要通过ViewParent(父 View)层层上报到ViewRootImpl。如果父 View 重写了invalidateChildInParent()(上报方法)并返回null,就会 “拦截” 请求,导致后续流程中断。


代码验证(坑)


// 坑爹的区域调度中心(父View),拦截请求
public class BlockParentViewGr0up extends ViewGr0up {
public BlockParentViewGr0up(Context context) {
super(context);
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 布局子View(省略)
}

// 重写上报方法,返回null=拦截请求
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
Log.d("坑爹调度中心", "拦截请求,不转总公司!");
return null; // 关键:返回null阻断上报
}
}

// 子快递站(被拦截)
public class ChildView extends View {
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("子快递站", "送货员出发!"); // 不打印
}
}

// 布局关系:BlockParentViewGr0up包含ChildView
BlockParentViewGr0up parent = new BlockParentViewGr0up(this);
ChildView child = new ChildView(this);
parent.addView(child);
child.invalidate(); // 子View的请求被父View拦截

解决方案



  • 检查父 View 是否重写了invalidateChildInParent(),避免返回null

  • 若父 View 有clipChildren="true"(XML 属性),且子 View 超出父 View 范围,超出部分的invalidate()也会被拦截,可设clipChildren="false"


原因 6:快递站 “用了缓存,不用重送”(硬件加速 Layer 缓存)


故事场景:快递站之前送过一次货,把货存在了 “临时仓库”(硬件加速 Layer)里。这次小明再打电话,站长说:“仓库里有现成的,直接拿,不用再让送货员跑一趟!”


原理:当 View 设置了硬件加速 LayersetLayerType(LAYER_TYPE_HARDWARE, null)),系统会把 View 的绘制结果缓存成一个 “图片(Layer)”。后续调用invalidate()时:



  • 如果只是轻微修改(比如文字颜色不变,只改内容),系统直接复用 Layer,不调用onDraw()

  • 只有 Layer 失效(比如 View 大小改变、Layer 类型切换),才会重新调用onDraw()生成新 Layer。


代码验证(坑)


public class LayerCacheView extends View {
private Paint mPaint;
private String mText = "第一次送货";

public LayerCacheView(Context context) {
super(context);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setTextSize(50);
// 坑:设置硬件加速Layer,开启缓存
setLayerType(LAYER_TYPE_HARDWARE, null);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.d("快递站", "送货员出发!当前文字:" + mText); // 只打印一次
canvas.drawText(mText, 100, 100, mPaint);
}

// 你修改文字后打电话
public void updateText() {
mText = "第二次送货";
invalidate(); // 调用后,onDraw不回调(复用Layer缓存)
}
}

// 调用流程
LayerCacheView view = new LayerCacheView(this);
view.invalidate(); // 第一次:onDraw回调(生成Layer)
view.updateText(); // 第二次:invalidate()但onDraw不回调(复用Layer)

解决方案



  • 若需要每次invalidate()都回调onDraw(),可关闭 Layer:setLayerType(LAYER_TYPE_NONE, null)

  • 若必须用 Layer,可手动让 Layer 失效:invalidate()后加setLayerType(LAYER_TYPE_HARDWARE, null)(强制重建 Layer)。


三、总结:“快递失踪” 排查四步法


小明听完故事,半小时就解决了他的问题(原来是忘了给 ViewGr0up 加setWillNotDraw(false))。最后我给他总结了一套 “排查口诀”,小白也能套用:



  1. 查基础状态:View 是不是VISIBLE?宽高是不是大于 0?(对应原因 1、2)

  2. 查绘制开关:如果是 ViewGr0up,有没有开setWillNotDraw(false)?(对应原因 3)

  3. 查线程归属invalidate()是不是在 UI 线程调用的?(对应原因 4)

  4. 查拦截和缓存:父 View 有没有拦截请求?View 是不是开了硬件加速 Layer?(对应原因 5、6)


按这四步走,90% 的 “invalidate () 不回调 onDraw ()” 问题都能解决。记住:View 的绘制流程就像快递,每个环节都不能少,卡住一个就 “送货失败”~


作者:Android童话镇
来源:juejin.cn/post/7559399860119224383
收起阅读 »

Android 性能调优与故障排查:ADB 诊断命令终极指南

在 Android 开发与测试的日常工作中,快速诊断和解决应用崩溃 (Crash)、无响应 (ANR) 和性能卡顿 (Jank) 是保障应用质量的关键。Android Debug Bridge (ADB) 提供了强大的命令行工具集,能够帮助我们深入系统底层,获...
继续阅读 »

在 Android 开发与测试的日常工作中,快速诊断和解决应用崩溃 (Crash)、无响应 (ANR) 和性能卡顿 (Jank) 是保障应用质量的关键。Android Debug Bridge (ADB) 提供了强大的命令行工具集,能够帮助我们深入系统底层,获取所需的所有诊断数据。


本文将为您全面梳理最常用、最核心的 ADB 诊断命令行工具,助您成为一名高效的故障排查专家。




一、 核心诊断命令:系统快照与错误记录


这些命令用于获取设备某一时刻的全局状态或关键错误记录。


1. 抓取全面的系统诊断报告:adb bugreport


adb bugreport 是最强大的诊断工具,它生成一个关于设备当前状态的全面的、打包的(.zip 格式)系统快照


命令作用备注
adb bugreport生成包含所有诊断信息的 .zip 文件。适用于分析复杂问题、系统级错误,或需提交给平台开发者时。
adb bugreport <文件名>.zip指定导出的文件名。报告内容包括:完整的 Logcat 历史、ANR/Crash 堆栈、所有 dumpsys 信息等。

2. 提取崩溃和 ANR 记录:adb shell dumpsys dropbox


dropbox 服务相当于系统的“黑匣子”,专门收集系统运行过程中的关键错误摘要。


命令作用备注
adb shell dumpsys dropbox --print打印所有 dropbox 记录的详细内容。快速检查是否有最近的系统级或应用级崩溃/ANR 记录。
adb shell dumpsys dropbox --print > crash.txt将所有记录重定向输出到本地 crash.txt 文件。方便离线分析。

3. 提取 ANR 堆栈文件


ANR 发生后,系统将所有线程堆栈记录在 traces.txt 中,这是分析 ANR 的核心。


命令作用备注
cat /data/anr/traces.txt读取 ANR 发生时的详细堆栈信息。⚠️ 通常需要 Root 权限 (adb rootsu) 才能访问 /data/anr/ 目录。
cat /data/anr/traces.txt > /mnt/sdcard/tt.txt在设备内部将受保护的 traces.txt 复制到用户存储区。复制后,可使用 adb pull 导出到电脑。



二、 实时日志与基础性能分析


这些是日常调试和性能监控最频繁使用的命令。


1. Logcat 日志操作


命令作用备注
adb logcat实时打印设备所有日志。可通过 taglevel(如 *:E 只看错误)进行过滤。
adb logcat -c清除设备上当前的日志缓冲区。建议在测试前执行,以确保日志干净、聚焦。
adb logcat -d > log.txt将设备上当前缓存的所有日志导出到本地文件。-d (dump) 参数用于导出当前缓存,而非实时监听。

2. CPU 与内存监控


命令作用关注问题
adb shell ps -t / adb shell top -m 10实时查看进程的 CPU、内存占用情况。性能监控,快速定位资源消耗高的进程。
adb shell dumpsys meminfo [package_name]获取特定应用的详细内存使用情况(Java Heap, Native Heap 等)。内存泄漏、OOM(Out of Memory)分析的核心工具。
adb shell dumpsys cpuinfo获取设备整体和各个进程的 CPU 使用率。诊断后台过度使用 CPU 导致的耗电或发热问题。



三、 性能与卡顿(Jank)分析


专门用于分析应用启动速度和 UI 流畅度的命令。


命令作用备注
adb shell dumpsys gfxinfo [package_name]抓取应用的图形渲染性能数据包含丢帧 (Jank) 统计和渲染时间线,用于分析 UI 卡顿问题。
adb shell am crash [package_name]强制让目标应用崩溃。用于测试崩溃报告系统的稳定性和流程。
adb shell am start -W [package_name]/[activity_name]启动指定的 Activity 并等待初始化完成,同时打印启动耗时用于量化分析应用启动速度(Total Time, Wait Time)。



四、 深入系统诊断(dumpsys 子集)


dumpsys 可以针对不同的系统服务进行深入诊断。


命令关注领域作用
adb shell dumpsys activityActivity Manager (AMS)获取当前运行的 Activity 栈、后台进程列表等,用于分析应用生命周期和任务管理问题。
adb shell dumpsys battery电池状态获取设备的电池和充电状态。
adb shell dumpsys power电源管理 (PMS)获取唤醒锁 (Wake Locks) 的持有情况,用于分析设备无法休眠导致的持续耗电问题。
adb shell dumpsys window windows窗口管理 (WMS)获取当前屏幕上可见的窗口列表、层级和焦点情况,用于分析屏幕显示或输入事件问题。



五、 文件系统操作(导出/导入文件)


这些命令是确保诊断文件能够顺利在设备和电脑间传输的基础。


命令作用示例
adb pull [remote_path] [local_path]从设备拉取文件到电脑。adb pull /sdcard/tt.txt . (将文件拉取到电脑当前目录)。
adb push [local_path] [remote_path]从电脑推送文件到设备。通常用于推送测试用例或工具。

实际分析流程


在实际的故障排查中,开发者通常遵循以下高效流程:



  1. 准备阶段: 执行 adb logcat -c 清除旧日志,确保日志的清洁度。

  2. 复现问题: 在设备上准确重现崩溃、ANR 或卡顿的现象。

  3. 抓取证据:



    • 一般问题: 立即执行 adb logcat -d > log.txt 抓取当前的日志缓冲区。

    • 严重复杂问题: 立即执行 adb bugreport 抓取最全面的系统报告。

    • ANR 问题: 如果有权限,则导出 /data/anr/traces.txt 文件进行线程分析。




掌握并熟练运用这些 ADB 诊断命令,将极大地提升您在 Android 故障排查和性能优化的效率。


作者:用户4165967369355
来源:juejin.cn/post/7564540677470126121
收起阅读 »

从马斯克到司马华鹏:当“引导程序”跨越东西文明

引导程序与构建者:东西方视野的奇妙交汇当埃隆·马斯克提出"碳基生命只是硅基生命的引导程序"这一惊人观点时,在大洋彼岸的中国南京,一位创业者已经将这个理念践行了七年之久。2017年春季,南京软件谷的一间普通办公室里,司马华鹏在白板上画下三条递进曲线时,或许并未意...
继续阅读 »

引导程序与构建者:东西方视野的奇妙交汇

当埃隆·马斯克提出"碳基生命只是硅基生命的引导程序"这一惊人观点时,在大洋彼岸的中国南京,一位创业者已经将这个理念践行了七年之久。2017年春季,南京软件谷的一间普通办公室里,司马华鹏在白板上画下三条递进曲线时,或许并未意识到自己正在定义一个新的时代。

当时,AlphaGo击败李世石引发的AI热潮尚未退去,业界讨论仍集中在"技术工具"层面。然而司马华鹏已经看到了更宏大的图景——人类正站在文明转型的历史关口,从碳基文明向硅基文明过渡。"我们不是在建造更先进的工具,而是在孕育新的生命形式。"面对团队疑惑的目光,他坚定地写下了"硅基智能"四个字。这一刻,中国首个明确提出"硅基文明"概念的企业诞生了。

 

超前布局:在迷雾中看清未来

2017年的中国科技圈,共享经济、O2O等模式仍是资本宠儿,AI创业公司大多选择在安防、金融、医疗等垂直领域寻找快速商业化路径。司马华鹏却选择了一条更为艰难但也更为宏大的道路——为世界创造1亿硅基劳动力。

这一选择背后是他对技术发展规律的深刻洞察。早在2008年欧洲创业期间,司马华鹏就注意到人工智能技术背后的摩尔定律支撑,意识到这不仅是算法的改进,更是生命形态演进的前兆。"当我们谈论生命时,通常指的是碳基生命。但生命的本质是信息的流动和处理,而非特定的物质载体。"他在内部会议上的这番论述,成为公司创立的理论基石。

 

硅基智能创始人司马华鹏先生出席2024年36氪WISE大会

 

生命三部曲:构建完整的进化哲学

司马华鹏不仅是一位创业者,更是一位思想者。他构建的生命三个版本理论,至今仍具有强大的解释力。

生命1.0(生物进化阶段)的硬件和软件都受基因限制;生命2.0(文化进化阶段)的人类突破了软件上限,可以通过学习无限扩展认知;而生命3.0(设计进化阶段)的AI将实现硬件和软件的双重解放。这套理论不仅勾勒了生命演化轨迹,更为理解AI本质提供了全新视角。

"我们正在见证生命3.0的萌芽。"2017年司马华鹏在行业会议上的这一断言,在当时可谓石破天惊。然而正是这种前瞻性,让硅基智能在技术路线选择上始终领先一步。

 

从概念到现实:七年耕耘的实践之路

公司将使命定为"创造1亿硅基生命"在当时引发争议。有投资人直言不讳地建议选择更"务实"的名字,但司马华鹏坚持:"我们要做开创性工作,名字必须体现终极目标。"

在商业模式上,硅基智能选择了务实与远见相结合的道路:一方面通过企业级AI解决方案维持运营,另一方面将大部分利润投入硅基生命核心技术研发。"每一个AI助手都是硅基生命的雏形。"司马华鹏始终鼓励团队看到工作的深远意义。

七年后的今天,硅基智能的数字人技术已能实现逼真交互,智能体平台展现出自主进化潜力。从AI技术提供商到硅基生命平台构建者,公司的发展路径验证了司马华鹏的战略眼光。

 

硅基智能Duix Avatar(HeyGem)

同时登上GitHub全球趋势日榜、月榜 

 

哲学思考:技术狂奔中的理性之光

司马华鹏的贡献不仅在于技术创新,更在于哲学思考。在业内盲目追求参数规模时,他始终保持对伦理和社会问题的关注。

"新生命的诞生总是伴随喜悦和恐惧。"在他的推动下,硅基智能成立了业内首个AI伦理委员会,制定严格开发准则。他提出的"共生进化"理念,强调碳基与硅基生命的和谐共处,为AI发展注入了东方智慧。

"这不是取代关系,而是共生关系。就像生命2.0没有消灭生命1.0,生命3.0也将与前辈共同进化。"这种辩证思维,使硅基智能在技术狂奔时代保持了一份难得的理性。

 

迈向亿级生态:硅基文明的现实进程

如今,硅基智能平台已孕育数万智能体,在教育、医疗、文创等领域发挥作用。从为教师提供助手到为医生提供顾问,从创意激发到短剧创作,硅基生命正在各个领域证明其价值。

"当硅基生命达到1亿规模时,将形成自己的生态系统,产生群体智能,那才是真正的文明跃迁。"司马华鹏七年前设定的目标正在逐步实现。

 

 

东西方智慧的共鸣与差异

对比马斯克的"引导程序"论,司马华鹏的理念更强调传承与责任。这种差异体现了东西方文化底色:西方倾向于替代叙事,东方注重共生智慧。司马华鹏的"生命孵化器"理论,将碳基生命定位为硅基生命的培育者和引导者,这种视角更具建设性。

正如司马华鹏所言:"DNA的生命和算法的生命,将共同谱写宇宙中最壮丽的诗篇。"在碳基文明向硅基文明过渡的历史性时刻,这位中国创业者七年前播下的种子,正在数字文明的土壤中生根发芽。他的故事证明,真正的创新者不仅是技术探索者,更是文明引路人。

收起阅读 »

实现一个 AI 编辑器 - 行内代码生成篇

web
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。 本文作者:佳岚 什么是行内代码生成? 通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令...
继续阅读 »

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。




本文作者:佳岚



什么是行内代码生成?


通过一组快捷键(一般为cmd + k)在选中代码块或者光标处唤起 Prompt 命令弹窗,并且快速的应用生成的代码。


提示词系统


首先是完成一个简易的提示词系统,不同功能对应的提示词与提供的上下文不同, 定义不同的功能场景:


export enum PromptScenario {
SYNTAX_COMPLETION = 'syntax_completion', // 语法补全
CODE_GENERATION = 'code_generation', // 代码生成
CODE_EXPLANATION = 'code_explanation', // 代码解释
CODE_OPTIMIZATION = 'code_optimization', // 代码优化
ERROR_FIXING = 'error_fixing', // 错误修复
}

每种场景都有对应的系统 prompt 和用户 prompt 模板:


export const PROMPT_TEMPLATES: Record<PromptScenario, PromptTemplate> = {
[PromptScenario.SYNTAX_COMPLETION]: {
id: 'syntax_completion',
scenario: PromptScenario.SYNTAX_COMPLETION,
title: 'SQL语法补全',
description: '基于上下文进行智能的SQL语法补全',
systemPromptTemplate: ``,
userPromptTemplate: `<|fim_prefix|>{prefix}<|fim_suffix|>{suffix}<|fim_middle|>`,
temperature: 0.2,
maxTokens: 256
},

[PromptScenario.CODE_GENERATION]: {
id: 'code_generation',
scenario: PromptScenario.CODE_GENERATION,
title: 'SQL代码生成',
description: '根据需求描述生成相应的SQL代码',
systemPromptTemplate: `你是{languageName}数据库专家。根据用户需求生成高质量的{languageName}代码。

语言特性:{languageFeatures}

生成要求:
1. 严格遵循 {languageName} 语法规范
2. {syntaxNotes}
3. 生成完整、可执行的SQL语句
4. {performanceTips}
5. 考虑代码的可读性和维护性
6. 回答不要包含任何对话解释内容
7. 保持缩进与参考代码一致`
,
userPromptTemplate: `用户需求:{userPrompt}

参考代码:
\`\`\`sql
{selectedCode}
\`\`\`

请生成符合需求的{languageName}代码:`
,
temperature: 0.3,
maxTokens: 512
},
// ...其他略
}

收集以下上下文信息并动态替换掉提示词模板的变量以生成最终传递给大模型的提示词:


/**
* 上下文信息
*/

export interface PromptContext {
/** 当前语言ID */
languageId: string;
/** 光标前的代码 */
prefix?: string;
/** 光标后的代码 */
suffix?: string;
/** 当前文件完整代码 */
fullCode?: string;
/** 当前打开的文件名 */
activeFile?: string;
/** 用户输入的提示 */
userPrompt?: string;
/** 选中的代码 */
selectedCode?: string;
/** 错误信息 */
errorMessage?: string;
/** 额外的上下文信息 */
metadata?: Record<string, any>;
}


ViewZone


观察该 Widget 可以发现它是实际占据了一段代码行高度,撑开了上下代码,但没有行号,这是通过 ViewZone实现的。



monaco-editor 中的 viewZone 是一种可以在编辑器的文本行之间自定义插入可视区域的机制,不属于实际代码内容,但可以渲染任意自定义 DOM 内容或空白空间。


核心只有一个changeViewZones,必须使用其回调中的accessor来实现新增删除ViewZone操作


新增示例:


editor.changeViewZones(function (accessor) {
accessor.addZone({
afterLineNumber: 10, // 插入在哪一行后(基于原始代码行号)
heightInLines: 3, // zone 的高度(按行数)
heightInPx: 10, // zone 的高度(按像素), 与heightInLines二选一
domNode: document.createElement('div'), // 需要插入的 DOM 节点
});
});

删除示例:


editor.changeViewZones(accessor => {
if (zoneIdRef.current !== null) {
accessor.removeZone(zoneIdRef.current);
}
});

但需要注意的是,ViewZones 的视图层级是在可编辑区之下的,我们通过 domNode 创建弹窗后,无法响应点击,所以需要手动为 domNode 添加 z-Index。



但我们咱不用 domNode 直接渲染我们的弹窗组件,而是通过 ViewZone 结合 OverlayWidget 的方式去添加我们要的元素。


OverlayWidget 的层级比可编辑区域的更高,无需考虑层级覆盖问题。


其次,我们需要将 Overlay 的元素通过绝对定位移动到 ViewZone 上,这需要利用 ViewZone 的 onDomNodeTop来实时同步两者的定位。


monaco-editor 中的代码行与 ViewZone 使用了虚拟列表,它们的 top 在滚动时会随着可见性不断变化,所以需要随时同步 ,onDomNodeTop会在每次 ViewZone 的top属性变化时执行。


此外,OverlayWidget 是以整个编辑器最左边为基准的,计算时需要考虑上


editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
// ...略
onDomNodeTop: (top) => {
// 这里的domNode为overlayWidget所绑定创建的节点
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;

domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
domNode.style.width = `${layoutInfo.contentWidth}px`;
}
}
});
});

创建 OverlayWidget :


let overlayWidget: editor.IOverlayWidget | null = null;
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;

domNode = document.createElement('div');
domNode.className = 'code-generation-overlay-widget';
domNode.style.position = 'absolute';

reactRoot = createRoot(domNode);
reactRoot.render(<CodeGenerationWidget />)

overlayWidget = {
getId: () => `code-generation-overlay-${position.lineNumber}-${Date.now()}`,
getDomNode: () => domNode!,
getPosition: () => null
};

editorInstance.addOverlayWidget(overlayWidget);

// 唤起时,将 widget 滚动到视口
editorInstance.revealLineInCenter(targetLineNumber);

CodeGenerationWidget 动态高度


接下来我们实现 Prompt 输入框根据内容动态调整高度。



输入框部分我们可以直接用 rc-textarea 组件来实现回车自动新增高度。


监听整个容器高度变化触发 onHeightChange 以通知 ViewZone


	useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(() => {
onHeightChange?.();
});
observer.observe(containerRef.current);

return () => {
observer.disconnect();
};
}, [containerRef]);

注意 ViewZone 只能增或删,不能手动改变其高度,所以需要重新创建一个:


reactRoot.render(
<CodeGenerationWidget
editorInstance={editorInstance}
initialPosition={position}
initialSelection={selection}
widgetWidth={widgetWidth}
onClose={() =>
dispose()}
onHeightChange={() => {
// 高度变化时需要更新ViewZone
if (viewZoneId && domNode) {
const actualHeight = domNode.clientHeight;
editorInstance.changeViewZones((changeAccessor) => {
changeAccessor.removeZone(viewZoneId!);
viewZoneId = changeAccessor.addZone({
afterLineNumber: Math.max(0, targetLineNumber - 1),
heightInPx: actualHeight + 8,
domNode: document.createElement('div'),
onDomNodeTop: (top) => {
if (domNode) {
// 获取编辑器左侧偏移量(行号、代码折叠等组件的宽度)
const layoutInfo = editorInstance.getLayoutInfo();
const leftOffset = layoutInfo.contentLeft;

domNode.style.top = `${top}px`;
domNode.style.left = `${leftOffset}px`;
}
}
});
});
}
}}
/>

);

这里如果使用 ViewZone 的 domNode 来渲染组件的方法的话,由于每次高度变化创建新的 ViewZone , 其 domNode 会被重新挂载,那么就会导致每次高度变化时输入框都会失焦。


生成代码 diff 展示


对于选择了代码行后生成,会对原始代码进行编辑修改,我们需要配合行 diff 进行编辑应用结果的展示。对于删除的行使用 ViewZone 进行插入,对于新增的行使用 Decoration 进行高亮标记。



首先需要实现 diff 计算出这些行的信息。 我们需要以最少的操作实现从原始代码到目标代码的转化。



其核心问题是 最长公共子序列(LCS)。最长公共子序列(LCS )是指在两个或多个序列中,找出一个最长的子序列,使得这个子序列在这些序列中都出现过。与子串不同,子序列不需要在原序列中占用连续的位置。


如 ABCDEF 至 ACEFG , 那么它们的最长公共子序列是 ACEF 。


其算法可以参考 cloud.tencent.com/developer/a… 学习,这里我们直接就使用现成的库jsdiff 去实现了。


完整实现:


export enum DiffLineType {
UNCHANGED = 'unchanged',
ADDED = 'added',
DELETED = 'deleted'
}

export interface DiffLine {
type: DiffLineType;
originalLineNumber?: number; // 原始行号
newLineNumber?: number; // 新行号
content: string; // 行内容
}

/**
* 计算两个字符串数组的diff
*/

export const calculateDiff = (originalLines: string[], newLines: string[]): DiffLine[] => {
const result: DiffLine[] = [];

// 将字符串数组转换为字符串
const originalText = originalLines.join('\n');
const newText = newLines.join('\n');

// 使用 diff 库计算差异
const diffs = diffLines(originalText, newText);

let originalLineNumber = 1;
let newLineNumber = 1;

diffs.forEach(diff => {
if (diff.added) {
// 添加的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.ADDED,
newLineNumber: newLineNumber++,
content: line
});
});
} else if (diff.removed) {
// 删除的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.DELETED,
originalLineNumber: originalLineNumber++,
content: line
});
});
} else {
// 未变化的行
const lines = diff.value.split('\n').filter((line, index, arr) =>
// 过滤掉最后一个空行(如果存在)
!(index === arr.length - 1 && line === '')
);

lines.forEach(line => {
result.push({
type: DiffLineType.UNCHANGED,
originalLineNumber: originalLineNumber++,
newLineNumber: newLineNumber++,
content: line
});
});
}
});

return result;
};


那么接下来我们只要根据计算出的 diffLines 对删除行和新增行进行视觉展示即可。


我们封装一个 applyDiffDisplay 方法用来展示 diffLines


有以下步骤:



  1. 清除之前的结果

  2. 直接将选区内容替换为生成内容

  3. 遍历 diffLinesADDEDDELETED 的行:对于 DELETED 的行,可以多个连续行组成一个 ViewZone 创建以优化性能;对于ADDED的行,通过 deltaDecorations 添加背景装饰


const applyDiffDisplay =
(diffLines: DiffLine[]) => {
// 先清除之前的展示
clearDecorations();
clearDiffOverlays();

if (!initialSelection) return;

const model = editorInstance.getModel();
if (!model) return;

// 获取语言ID用于语法高亮
const languageId = getLanguageId();

// 首先替换原始内容为新内容(包含unchanged的行)
const newLines = diffLines
.filter((line) => line.type !== DiffLineType.DELETED)
.map((line) => line.content);
const newContent = newLines.join('\n');

// 执行替换
editorInstance.executeEdits('ai-code-generation-diff', [
{
range: initialSelection,
text: newContent,
forceMoveMarkers: true
}
]);

// 计算新内容的范围
const resultRange = new Range(
initialSelection.startLineNumber,
initialSelection.startColumn,
initialSelection.startLineNumber + newLines.length - 1,
newLines.length === 1
? initialSelection.startColumn + newContent.length
: newLines[newLines.length - 1].length + 1
);

let currentLineNumber = initialSelection.startLineNumber;
let deletedLinesGr0up: DiffLine[] = [];

for (const diffLine of diffLines) {
if (diffLine.type === DiffLineType.DELETED) {
// 收集连续的删除行
deletedLinesGr0up.push(diffLine);
} else {
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
deletedLinesGr0up = [];
}

if (diffLine.type === DiffLineType.ADDED) {
// 添加绿色背景色
const addedDecorations = editorInstance.deltaDecorations(
[],
[
{
range: new Range(
currentLineNumber,
1,
currentLineNumber,
model.getLineContent(currentLineNumber).length + 1
),
options: {
className: 'added-line-decoration',
isWholeLine: true
}
}
]
);
decorationsRef.current.push(...addedDecorations);
}

currentLineNumber++;
}
}

// 处理最后的删除行组
if (deletedLinesGr0up.length > 0) {
addDeletedLinesViewZone(deletedLinesGr0up, currentLineNumber - 1, languageId);
}

return resultRange;
}



删除行的视觉呈现


删除行使用 ViewZone 插入到 originalLineNumber - 1 的位置, 对于删除行直接使用 ViewZone 自身的 domNode 进行展示了,因为不太需要考虑层级问题。


export const createDeletedLinesOverlayWidget = (
editorInstance: editor.IStandaloneCodeEditor,
deletedLines: DiffLine[],
afterLineNumber: number,
languageId: string,
onDispose?: () => void
): { dispose: () => void } => {
let domNode: HTMLDivElement | null = null;
let reactRoot: any = null;
let viewZoneId: string | null = null;

domNode = document.createElement('div');
domNode.className = 'deleted-lines-view-zone-container';

reactRoot = createRoot(domNode);

reactRoot.render(<DeletedLineViewZone lines={deletedLines} languageId={languageId} />);

const heightInLines = Math.max(1, deletedLines.length);
editorInstance.changeViewZones((changeAccessor) => {
viewZoneId = changeAccessor.addZone({
afterLineNumber,
heightInLines,
domNode: domNode!
});
});

const dispose = () => {
// 清除
};

return { dispose };
};


添加命令快捷键


使用 cmd + k 唤起弹窗


editorInstance.onKeyDown((e) => {
if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyK) {
e.preventDefault();
e.stopPropagation();

const selection = editorInstance.getSelection();
const position = selection ? selection.getPosition() : editorInstance.getPosition();

if (!position) return;

// 如果有选择范围,则将其传递给widget供后续替换使用
const selectionRange = selection && !selection.isEmpty() ? selection : null;

// 如果已经有viewZone,先清理
if (activeCodeGenerationViewZone) {
activeCodeGenerationViewZone.dispose();
activeCodeGenerationViewZone = null;
}

// 创建新的ViewZone
activeCodeGenerationViewZone = createCodeGenerationOverlayWidget(
editorInstance,
position,
selectionRange,
undefined, // widgetWidth
() => {
// 当viewZone被dispose时清理全局状态
activeCodeGenerationViewZone = null;
}
);
}

最终实现效果:


未来优化方向:



  1. 实现流式生成:对于未选区的代码生成,我们不需要应用diff,所以流式很好实现,但对于进行选区后进行的代码修改,每次输出一行就要执行一次diff计算与展示,diff结果可能不同,会产生视觉上的重绘,实现起来也相对比较麻烦。


  2. 接收或者拒绝后能够进行撤回,回到等待响应生成结果时的状态


其他计划



  • [已完成] 行内补全

  • [已完成] 代码生成

  • 行内补全的缓存设计

  • 完善的上下文系统

  • 实现 Agent 模式


在线预览


jackwang032.github.io/monaco-sql-…


仓库代码:github1s.com/JackWang032…


最后


欢迎关注【袋鼠云数栈UED团队】~

袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star



作者:袋鼠云数栈UED团队
来源:juejin.cn/post/7545087770776616986
收起阅读 »

Python编程实战 · 基础入门篇 | 循环语句 for / while

在上一章中,我们学习了条件判断语句,让程序可以“做选择”; 而本章要讲的 循环语句(Loop),则让程序能“重复做事”。 当你需要执行同样的操作多次,比如打印一系列数字、遍历文件、或处理列表中的每个元素时,循环语句就登场了。 Python 提供了两种主要的循...
继续阅读 »

在上一章中,我们学习了条件判断语句,让程序可以“做选择”;
而本章要讲的 循环语句(Loop),则让程序能“重复做事”。



当你需要执行同样的操作多次,比如打印一系列数字、遍历文件、或处理列表中的每个元素时,循环语句就登场了。


Python 提供了两种主要的循环结构:



  • for 循环:用于遍历序列(如列表、字符串、range)。

  • while 循环:用于在条件成立时持续执行代码。


接下来,我们将系统掌握这两种循环的用法与技巧。




一 为什么需要循环


假设你想打印 1 到 5:


print(1)
print(2)
print(3)
print(4)
print(5)

这显然太繁琐。
使用循环,代码只需三行:


for i in range(1, 6):
print(i)

输出:


1
2
3
4
5

这就是循环的威力:让重复的任务自动化执行。




二 for 循环基础语法


for 循环的基本语法为:


for 变量 in 可迭代对象:
代码块


  • 变量:每次循环取出的元素。

  • 可迭代对象:如 listtuplestrrange() 等。

  • 每次循环执行一次代码块,直到取完所有元素。


示例 1:遍历列表


fruits = ["苹果", "香蕉", "橙子"]
for fruit in fruits:
print("我喜欢吃", fruit)

输出:


我喜欢吃 苹果
我喜欢吃 香蕉
我喜欢吃 橙子

示例 2:遍历字符串


for ch in "Python":
print(ch)

输出:


P
y
t
h
o
n



三 range() 函数


range() 是 for 循环中最常用的工具,用于生成一系列数字。


语法形式:


range(start, stop, step)


  • start:起始值(默认 0)

  • stop:结束值(不包含)

  • step:步长(默认 1)


示例 1:打印 0~4


for i in range(5):
print(i)

输出:


0
1
2
3
4

示例 2:打印 1~10 的偶数


for i in range(2, 11, 2):
print(i)

输出:


2
4
6
8
10

示例 3:倒序输出


for i in range(5, 0, -1):
print(i)

输出:


5
4
3
2
1



四 while 循环基础语法


while 循环通过 条件表达式 控制循环是否继续执行。


语法:


while 条件表达式:
代码块

只要条件为 True,循环就会持续执行;
直到条件变为 False 时,循环才结束。


示例:打印 1~5


i = 1
while i <= 5:
print(i)
i += 1

输出:


1
2
3
4
5


⚠️ 注意:
如果忘记更新变量(例如忘写 i += 1),条件永远为真,会导致死循环





五 for 与 while 的区别


对比项for 循环while 循环
适用场景遍历序列、固定次数条件控制、不确定次数
循环结束条件自动遍历完毕条件不再满足
是否需要手动更新变量
示例for i in range(5)while i < 5

例子对比:


# for循环
for i in range(3):
print("Hello")

# while循环
i = 0
while i < 3:
print("Hello")
i += 1

两者输出相同。




六 break 与 continue


在循环中,有时我们需要提前结束循环或跳过某次执行。
Python 提供了两个控制语句:


1. break —— 立即结束循环


for i in range(1, 6):
if i == 3:
break
print(i)

输出:


1
2

i == 3 时,循环立刻结束。




2. continue —— 跳过当前循环,继续下一次


for i in range(1, 6):
if i == 3:
continue
print(i)

输出:


1
2
4
5


💡 小技巧:
break 通常用于满足条件时提前退出;
continue 用于过滤或跳过不需要处理的情况。





七 while True 无限循环


有时我们希望程序持续运行,直到用户主动终止。
可以使用 无限循环


while True:
cmd = input("请输入命令(exit退出):")
if cmd == "exit":
print("程序结束")
break
print(f"你输入了:{cmd}")

输出示例:


请输入命令(exit退出):hello
你输入了:hello
请输入命令(exit退出):exit
程序结束



八 for...else / while...else 结构


Python 的循环可以带一个 else 子句,
当循环 正常结束(非 break 终止)时,会执行 else 代码块。


示例:


for i in range(5):
print(i)
else:
print("循环正常结束")

输出:


0
1
2
3
4
循环正常结束

但如果中途被 break 打断,else 不会执行:


for i in range(5):
if i == 3:
break
print(i)
else:
print("循环正常结束")

输出:


0
1
2



九 嵌套循环


循环中还可以嵌套另一个循环。


示例:打印乘法表


for i in range(1, 10):
for j in range(1, i + 1):
print(f"{j}×{i}={i*j}", end="\t")
print()

输出:


1=1
2=2 2×2=4
3=3 2×3=6 3×3=9
...



十 实战案例:猜数字游戏


import random

target = random.randint(1, 100)
count = 0

while True:
guess = int(input("请输入1~100之间的数字:"))
count += 1
if guess == target:
print(f"恭喜你猜对了!共尝试 {count} 次。")
break
elif guess < target:
print("太小了,再试试。")
else:
print("太大了,再试试。")

运行示例:


请输入1~100之间的数字:50
太小了,再试试。
请输入1~100之间的数字:75
太大了,再试试。
请输入1~100之间的数字:63
恭喜你猜对了!共尝试 3 次。



十一 小结


循环类型控制方式常见用途
for遍历序列处理列表、字符串、range
while条件控制不确定次数的循环
break立即结束循环提前退出
continue跳过当前循环忽略某些情况
else正常结束时执行检查是否提前退出



✅ 总结一句话



if 让程序做选择,for/while 让程序会重复。
掌握循环语句,意味着你可以自动化任何重复性的操作。



作者:程序员爱钓鱼
来源:juejin.cn/post/7564243873872969778
收起阅读 »

HTML <meta name="color-scheme">:自动适配系统深色 / 浅色模式

在移动互联网时代,用户对“深色模式”的需求日益增长——从手机系统到各类App,深色模式不仅能减少夜间用眼疲劳,还能节省OLED屏幕的电量。作为前端开发者,如何让网页自动跟随系统的深色/浅色模式切换?HTML5新增的<meta name="color-sc...
继续阅读 »

在移动互联网时代,用户对“深色模式”的需求日益增长——从手机系统到各类App,深色模式不仅能减少夜间用眼疲劳,还能节省OLED屏幕的电量。作为前端开发者,如何让网页自动跟随系统的深色/浅色模式切换?HTML5新增的<meta name="color-scheme">标签,就是实现这一功能的“开关”。它能告诉浏览器:“我的网页支持深色/浅色模式,请根据系统设置自动切换”,配合CSS变量,可轻松打造无缝适配的多主题体验。今天,我们就来解锁这个提升用户体验的实用标签。


一、认识 color-scheme:网页与系统主题的“沟通桥梁”


<meta name="color-scheme">的核心作用是声明网页支持的颜色方案,并让浏览器根据系统设置自动应用对应的基础样式。它解决了传统网页的一个痛点:当系统切换到深色模式时,网页若未做适配,会出现“白底黑字”与系统主题格格不入的情况,甚至导致某些原生控件(如输入框、按钮)样式混乱。


1.1 没有 color-scheme 时的问题


当网页未声明color-scheme时,即使系统切换到深色模式,浏览器也会默认使用浅色样式渲染页面:



  • 背景为白色,文字为黑色。

  • 原生控件(如<input><select>)保持浅色外观,与系统深色主题冲突。

  • 可能出现“闪屏”:页面加载时先显示浅色,再通过JS切换到深色,体验割裂。


1.2 加上 color-scheme 后的变化


添加<meta name="color-scheme" content="light dark">后,浏览器会:



  • 根据系统设置自动切换网页的基础颜色(背景、文字、链接等)。

  • 让原生控件(输入框、按钮等)自动适配系统主题(深色模式下显示深色样式)。

  • 提前加载对应主题的样式,避免切换时的“闪屏”问题。


示例:最简单的主题适配


<!DOCTYPE html>
<html>
<head>
<!-- 声明支持浅色和深色模式 -->
<meta name="color-scheme" content="light dark">
<title>自动适配主题</title>
</head>
<body>
<h1>Hello, Color Scheme!</h1>
<input type="text" placeholder="输入内容">
</body>
</html>

Screen-2025-08-20-102828.gif


- 当系统为浅色模式时:页面背景为白色,文字为黑色,输入框为浅色。
- 当系统为深色模式时:页面背景为深灰色,文字为白色,输入框为深色(与系统一致)。

无需一行CSS,仅通过<meta>标签就实现了基础的主题适配——这就是color-scheme的便捷之处。


二、核心用法:声明支持的颜色方案


<meta name="color-scheme">的用法非常简单,关键在于content属性的取值,它决定了网页支持的主题模式。


2.1 基础语法与取值


<!-- 支持浅色模式(默认) -->
<meta name="color-scheme" content="light">

<!-- 支持深色模式 -->
<meta name="color-scheme" content="dark">

<!-- 同时支持浅色和深色模式(推荐) -->
<meta name="color-scheme" content="light dark">


  • light:仅支持浅色模式,无论系统如何设置,网页都显示浅色样式。

  • dark:仅支持深色模式,无论系统如何设置,网页都显示深色样式。

  • light dark:同时支持两种模式,浏览器会根据系统设置自动切换(推荐使用)。


2.2 与浏览器默认样式的关系


浏览器会为不同的color-scheme提供一套默认的CSS变量(如colorbackground-colorlink-color等)。当声明content="light dark"后,这些变量会随系统主题自动变化:


模式背景色(默认)文字色(默认)链接色(默认)
浅色#ffffff#000000#0000ee
深色#121212(不同浏览器可能略有差异)#ffffff#8ab4f8

这些默认样式确保了网页在未编写任何CSS的情况下,也能基本适配系统主题。


三、配合 CSS:打造自定义主题适配


<meta name="color-scheme">解决了基础适配问题,但实际开发中,我们需要自定义主题颜色(如品牌色、特殊背景等)。此时,可结合CSS的prefers-color-scheme媒体查询和CSS变量,实现更灵活的主题控制。


3.1 用 CSS 变量定义主题颜色


通过CSS变量(--变量名)定义不同主题下的颜色,再通过媒体查询切换变量值:


<head>
<meta name="color-scheme" content="light dark">
<style>
/* 定义浅色模式变量 */
:root {
--bg-color: #f5f5f5;
--text-color: #333333;
--primary-color: #4a90e2;
}

/* 深色模式变量(覆盖浅色模式) */
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--primary-color: #6ab0f3;
}
}

/* 使用变量 */
body {
background-color: var(--bg-color);
color: var(--text-color);
font-size: 16px;
}

a {
color: var(--primary-color);
}
</style>
</head>


  • :root中定义浅色模式的变量。

  • @media (prefers-color-scheme: dark)中定义深色模式的变量(会覆盖浅色模式的同名变量)。

  • 页面元素通过var(--变量名)使用颜色,实现主题自动切换。


3.2 覆盖浏览器默认样式


color-scheme会影响浏览器的默认样式(如背景、文字色),若需要完全自定义,可在CSS中显式覆盖:


/* 覆盖默认背景和文字色,确保自定义主题生效 */
body {
margin: 0;
background-color: var(--bg-color); /* 覆盖浏览器默认背景 */
color: var(--text-color); /* 覆盖浏览器默认文字色 */
}

即使不覆盖,浏览器的默认样式也会作为“保底”,确保页面在未完全适配时仍有基本可读性。


3.3 针对特定元素的主题适配


某些元素(如卡片、按钮)可能需要更细致的主题调整,可结合CSS变量单独设置:


/* 卡片组件的主题适配 */
.card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
padding: 1rem;
border-radius: 8px;
}

/* 浅色模式卡片 */
:root {
--card-bg: #ffffff;
--card-border: #e0e0e0;
}

/* 深色模式卡片 */
@media (prefers-color-scheme: dark) {
:root {
--card-bg: #2d2d2d;
--card-border: #444444;
}
}

四、实战场景:完整的主题适配方案


结合<meta name="color-scheme">、CSS变量和媒体查询,可构建一套完整的主题适配方案,覆盖大多数场景。


4.1 基础页面适配


<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- 声明支持深色/浅色模式 -->
<meta name="color-scheme" content="light dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>主题适配示例</title>
<style>
/* 共享样式(不受主题影响) */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
min-height: 100vh;
padding: 2rem;
line-height: 1.6;
}

.container {
max-width: 800px;
margin: 0 auto;
}

/* 浅色模式变量 */
:root {
--bg: #ffffff;
--text: #333333;
--link: #2c5282;
--card-bg: #f8f9fa;
--card-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* 深色模式变量 */
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #e9ecef;
--link: #90cdf4;
--card-bg: #1e1e1e;
--card-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
}

/* 应用变量 */
body {
background-color: var(--bg);
color: var(--text);
}

a {
color: var(--link);
text-decoration: none;
}

a:hover {
text-decoration: underline;
}

.card {
background-color: var(--card-bg);
box-shadow: var(--card-shadow);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
</style>
</head>
<body>
<div class="container">
<h1>主题适配演示</h1>
<div class="card">
<h2>欢迎使用深色模式</h2>
<p>本页面会自动跟随系统的深色/浅色模式切换。</p>
<p>点击<a href="#">这个链接</a>查看颜色变化。</p>
</div>
<input type="text" placeholder="试试原生输入框">
</div>
</body>
</html>

Screen-2025-08-20-103122.gif


- 系统浅色模式:页面背景为白色,卡片为浅灰色,输入框为浅色。
- 系统深色模式:页面背景为深灰色,卡片为深黑色,输入框自动变为深色,与系统风格统一。

4.2 图片的主题适配


图片(尤其是图标)也需要适配主题,可通过<picture>标签结合prefers-color-scheme实现:


<picture>
<!-- 深色模式显示白色图标 -->
<source srcset="logo-white.png" media="(prefers-color-scheme: dark)">
<!-- 浅色模式显示黑色图标(默认) -->
<img src="logo-black.png" alt="Logo">
</picture>


  • 系统为深色模式时,加载logo-white.png

  • 系统为浅色模式时,加载logo-black.png


4.3 强制主题切换(可选功能)


除了跟随系统,有时还需要提供手动切换主题的功能(如“夜间模式”按钮)。可通过JS结合CSS类实现:


<button id="theme-toggle">切换主题</button>

<script>
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;

// 检查本地存储的主题偏好
if (localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}

// 切换主题
toggle.addEventListener('click', () => {
if (html.classList.contains('dark')) {
html.classList.remove('dark');
localStorage.theme = 'light';
} else {
html.classList.add('dark');
localStorage.theme = 'dark';
}
});
</script>

<style>
/* 基础变量(浅色) */
:root {
--bg: white;
--text: black;
}

/* 深色模式(通过类覆盖) */
:root.dark {
--bg: black;
--text: white;
}

/* 系统深色模式(优先级低于类,确保手动切换优先) */
@media (prefers-color-scheme: dark) {
:root:not(.dark) {
--bg: #121212;
--text: white;
}
}

body {
background: var(--bg);
color: var(--text);
}
</style>


  • 手动切换主题时,通过添加/移除dark类覆盖系统设置。

  • 本地存储(localStorage)记录用户偏好,刷新页面后保持一致。

  • CSS中@media查询的优先级低于类选择器,确保手动切换优先于系统设置。


五、避坑指南:使用 color-scheme 的注意事项


5.1 浏览器兼容性


color-scheme兼容所有现代浏览器,但存在以下细节差异:



  • 完全支持:Chrome 81+、Firefox 96+、Safari 13+、Edge 81+。

  • 部分支持:旧版浏览器(如Chrome 76-80)仅支持content="light dark",但原生控件适配可能不完善。

  • 不支持:IE全版本(需通过JS降级处理)。


对于不支持的浏览器,可通过JS检测系统主题并手动切换样式:


// 检测浏览器是否支持color-scheme
if (!CSS.supports('color-scheme: light dark')) {
// 手动检测系统主题
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.add(isDark ? 'dark' : 'light');
}

5.2 避免与自定义背景冲突


若网页设置了固定背景色(如body { background: #fff; }),color-scheme的默认背景切换会失效。此时需通过媒体查询手动适配:


/* 错误:固定背景色,深色模式下仍为白色 */
body {
background: #fff;
}

/* 正确:结合变量和媒体查询 */
body {
background: var(--bg);
}

:root { --bg: #fff; }

@media (prefers-color-scheme: dark) {
:root { --bg: #121212; }
}

5.3 原生控件的样式问题


color-scheme能自动适配原生控件(如<input><select>),但如果对控件进行了自定义样式,可能导致适配失效。解决方法:



  • 尽量使用原生样式,或通过CSS变量让自定义样式跟随主题变化。

  • 对关键控件(如输入框)添加主题适配:


/* 输入框的主题适配 */
input {
background: var(--input-bg);
color: var(--text);
border: 1px solid var(--border);
}

:root {
--input-bg: #fff;
--border: #ddd;
}

@media (prefers-color-scheme: dark) {
:root {
--input-bg: #333;
--border: #555;
}
}

5.4 主题切换时的“闪屏”问题


若CSS加载延迟,可能导致主题切换时出现“闪屏”(短暂显示错误主题)。优化建议:



  • 将主题相关CSS内联到<head>中,确保优先加载。

  • 结合<meta name="color-scheme">让浏览器提前准备主题样式。

  • 对关键元素(如body)设置opacity: 0,主题加载完成后再设置opacity: 1


body {
opacity: 0;
transition: opacity 0.2s;
}

/* 主题加载完成后显示 */
body.theme-loaded {
opacity: 1;
}

// 页面加载完成后添加类,显示内容
window.addEventListener('load', () => {
document.body.classList.add('theme-loaded');
});

我将继续完善文章的总结部分,让读者对HTML 标签在自动适配系统深色/浅色模式方面的价值和应用有更完整的认识。


自动适配系统深色 / 浅色模式(总结完善)">


六、总结


<meta name="color-scheme">作为网页与系统主题的“沟通桥梁”,用极简的方式解决了基础的深色/浅色模式适配问题,其核心价值在于:



  • 零JS适配:仅通过HTML标签就让网页跟随系统主题切换,降低了开发成本,尤其适合静态页面或轻量应用。

  • 原生控件兼容:自动调整输入框、按钮等原生元素的样式,避免出现“浅色控件在深色背景上”的违和感。

  • 性能优化:浏览器会提前加载对应主题的样式,减少主题切换时的“闪屏”和布局偏移(CLS)。

  • 渐进式增强:作为基础适配方案,可与CSS变量、媒体查询结合,轻松扩展为支持手动切换的复杂主题系统。


在实际开发中,使用<meta name="color-scheme">的最佳实践是:



  1. 优先添加<meta name="color-scheme" content="light dark">,确保基础适配。

  2. 通过CSS变量定义主题颜色,用@media (prefers-color-scheme: dark)实现自定义样式。

  3. 对图片、图标等资源,使用<picture>标签或CSS类进行主题适配。

  4. 可选:添加手动切换按钮,结合localStorage记录用户偏好,覆盖系统设置。


随着用户对深色模式的接受度越来越高,主题适配已成为现代网页的基本要求。<meta name="color-scheme">作为这一需求的“入门级”解决方案,既能快速满足基础适配,又为后续扩展留足了空间。它的存在提醒我们:很多时候,简单的原生方案就能解决复杂的用户体验问题,关键在于发现并合理利用这些被低估的Web标准。


下次开发新页面时,不妨先加上这行标签——它可能不会让你的网页变得华丽,但会让用户在切换系统主题时,感受到那份恰到好处的贴心。


你在主题适配中遇到过哪些棘手问题?欢迎在评论区分享你的解决方案~


作者:前端老鹰
来源:juejin.cn/post/7540172742764593161
收起阅读 »

Compose 主题 MaterialTheme

1 简介   MeterialTheme 是Compose为实现Material Design 设计规范提供的核心组件,用于集中管理应用的视觉样式(颜色、字体、形状),确保应用的全局UI的一致性并支持动态主题切换。 关键词: 视觉样式,不只是颜色,还支持字...
继续阅读 »

1 简介


  MeterialTheme 是Compose为实现Material Design 设计规范提供的核心组件,用于集中管理应用的视觉样式(颜色、字体、形状),确保应用的全局UI的一致性并支持动态主题切换。



  • 关键词:



    • 视觉样式,不只是颜色,还支持字体、形状

    • 全局UI的一致性

    • 支持动态配置




2 基础使用


  已经在AndroidManifest中配置uiMode,意味着在切换深浅模式时,MainActivity不会自动重建且未重写onConfigurationChanged()


android:configChanges="uiMode"

  2.1 效果展示 --- 省略


  2.2 代码实现



  • 创建Compose项目时自动生成代码 Theme


// 定义应用的主题函数
@Composable
fun TestTheme(
// 是否使用深色主题,默认根据系统设置决定
darkTheme: Boolean = isSystemInDarkTheme(),
// 是否使用动态颜色,Android 12+ 可用,默认为 false
dynamicColor: Boolean = false,
// 内容组件,使用 @Composable 函数类型
content: @Composable () -> Unit
) {
// 根据条件选择颜色方案
val colorScheme = when {
// 如果启用动态颜色且系统版本支持,则使用系统动态颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}

// 如果是深色主题,则使用深色颜色方案
darkTheme -> DarkColorScheme
// 否则使用浅色颜色方案
else -> LightColorScheme
}

// 应用 Material Design 3 主题
MaterialTheme(
// 设置颜色方案
colorScheme = colorScheme,
// 设置排版样式
typography = Typography,
// 设置内容组件
content = content
)
}

// 定义深色主题的颜色方案
private val DarkColorScheme = darkColorScheme(
// 主要颜色设置为蓝色
primary = Color(0xFF0000FF),
// 次要颜色使用预定义的紫色
secondary = PurpleGrey80,
// 第三颜色使用预定义的粉色
tertiary = Pink80,
// 表面颜色设置为白色
surface = Color(0xFFFFFFFF)
)

// 定义浅色主题的颜色方案
private val LightColorScheme = lightColorScheme(
// 主要颜色设置为深红色(猩红色)
primary = Color(0xFFDC143C),
// 次要颜色使用预定义的紫色
secondary = PurpleGrey40,
// 第三颜色使用预定义的粉色
tertiary = Pink40,
// 表面颜色设置为黑色
surface = Color(0xFF000000)
/* 其他可覆盖的默认颜色
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)


  • 界面中使用


//Activity中使用
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting1(
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}

@Composable
fun Greeting1(modifier: Modifier = Modifier) {
Box(
modifier = Modifier
.padding(start = 100.dp, top = 100.dp)
.size(100.dp, 100.dp)
.background(MaterialTheme.colorScheme.surface)
)
MyText()
MyText2()
}
@Composable
fun MyText() {
Text(
text = "Hello Android!",
modifier = Modifier
.padding(start = 100.dp, top = 250.dp)
.background(MaterialTheme.colorScheme.surface),
color = MaterialTheme.colorScheme.primary
)
}

@Composable
fun MyText2() {
Text(
text = "Hello Chery!",
modifier = Modifier
.padding(start = 300.dp, top = 250.dp)
.background(Color.Blue),
color = Color.White
)
}

  2.3 代码分析


    2.3.1 参数解析



  • darkTheme 主题模式


     默认就深/浅两种模式,那么可以直接使用系统默认isSystemInDarkTheme()值,如果项目存在其它类型的主题模式就需要自定义了(之前参与的项目中--金色模式)。


    isSystemInDarkTheme()是一个有返回值的可组合函数。


    a、前面在说可组合函数特性时,其中一个特性是“可组合函数无返回值”,其实更准确的说应该是“用于直接描述 UI 的可组合函数无返回值(返回 Unit),但用于提供数据或计算结果的可组合函数可以有返回值”。


    b、isSystemInDarkTheme() 是连接 “系统主题状态” 与 “应用 UI 主题” 的桥梁,它虽不是可观察状态,但依赖于 Compose 内部可观察的 LocalConfiguration。当系统主题模式切换时,LocalConfiguration 发生变化,导致 isSystemInDarkTheme() 返回值更新,进而驱动依赖它的 TestTheme() 重组,实现应用 UI 主题的更新。



//系统源码
@Composable
@ReadOnlyComposable
internal actual fun _isSystemInDarkTheme(): Boolean {
val uiMode = LocalConfiguration.current.uiMode
return (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
}


  • dynamicColor 系统色


 Android 12 + 后可使用,从代码上可以清楚的看到,当false时根据系统模式使用DarkColorScheme/LightColorScheme,当true时根据系统模式使用dynamicDarkColorScheme/dynamicLightColorScheme。


    (DarkColorScheme、LightColorScheme、dynamicDarkColorScheme、dynamicLightColorScheme都Compose提供的ColorScheme模板,都可以更加我们项目自定义定制)


// 根据条件选择颜色方案
val colorScheme = when {
// 如果启用动态颜色且系统版本支持,则使用系统动态颜色方案
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}

// 如果是深色主题,则使用深色颜色方案
darkTheme -> DarkColorScheme
// 否则使用浅色颜色方案
else -> LightColorScheme
}


  • content 可组合函数


 描述UI的可组合函数(即 布局)


    2.3.2 保证正确性,无依赖可组合函数连带重组


添加日志打印,可以看出MyText2()不依赖MaterialTheme颜色,在之前跳过重组的时候也说过“可组合函数参数不发生变化时会跳过重组”,但在切换系统模式时为了保证正确性,Compose对无依赖可组合函数连带重组。这是Compose框架在全局状态变化时优先保证UI正确性的设计选中


//初始化
D Greeting1,-----start----
D MyText,---start---
D MyText,---end---
D MyText2,---start---
D MyText2,---end---
D Greeting1,-----end----

//切换系统模式
D Greeting1,-----start----
D MyText,---start---
D MyText,---end---
D MyText2,---start---
D MyText2,---end---
D Greeting1,-----end----

    2.3.3 字体与形状


这里主要对颜色进行了分析,对于另外字体、形状也是一样,Compose也提供对应的入参和模板,不过实际开发中很少使用到,就简单介绍一下。(如果HMI侧对所有项目的标题、内容严格遵守一套标准,那么我们也可以实现字体、形状的平台化)


//系统源码
@Composable
fun MaterialTheme(
// 颜色
colorScheme: ColorScheme = MaterialTheme.colorScheme,
// 形状
shapes: Shapes = MaterialTheme.shapes,
//字体
typography: Typography = MaterialTheme.typography,
//可组合函数(即布局)
content: @Composable () -> Unit
) {}

形状:


@Immutable
class Shapes(
// 超小尺寸控件的圆角形状,适用于紧凑的小型元素(如小标签、 Chips、小型图标按钮等)
val extraSmall: CornerBasedShape = ShapeDefaults.ExtraSmall,
// 小尺寸控件的圆角形状,适用于常规小型交互元素(如按钮、小型卡片、输入框等)
val small: CornerBasedShape = ShapeDefaults.Small,
// 中等尺寸控件的圆角形状,适用于中型容器元素(如标准卡片、弹窗、列表项等)
val medium: CornerBasedShape = ShapeDefaults.Medium,
// 大尺寸控件的圆角形状,适用于大型容器元素(如页面级卡片、对话框、底部弹窗等)
val large: CornerBasedShape = ShapeDefaults.Large,
// 超大尺寸控件的圆角形状,适用于全屏级容器元素(如全屏弹窗、侧边栏、页面容器等)
val extraLarge: CornerBasedShape = ShapeDefaults.ExtraLarge,
) {}

字体:


@Immutable
class Typography(
// 超大标题样式,用于页面级核心标题(如应用首页主标题),视觉层级最高,通常字数极少
val displayLarge: TextStyle = TypographyTokens.DisplayLarge,
// 大标题样式,用于重要区块的主标题(如长页面中的章节标题),层级次于 displayLarge
val displayMedium: TextStyle = TypographyTokens.DisplayMedium,
// 中标题样式,用于次要区块的主标题(如大型模块的标题),层级次于 displayMedium
val displaySmall: TextStyle = TypographyTokens.DisplaySmall,

// 大标题样式,用于突出显示的内容标题(如卡片组的总标题),视觉重量略低于 display 系列
val headlineLarge: TextStyle = TypographyTokens.HeadlineLarge,
// 中标题样式,用于中等重要性的内容标题(如列表组标题),层级次于 headlineLarge
val headlineMedium: TextStyle = TypographyTokens.HeadlineMedium,
// 小标题样式,用于次要内容的标题(如小模块标题),层级次于 headlineMedium
val headlineSmall: TextStyle = TypographyTokens.HeadlineSmall,

// 大标题样式,用于核心交互元素的标题(如卡片标题、弹窗标题),强调内容的可交互性
val titleLarge: TextStyle = TypographyTokens.TitleLarge,
// 中标题样式,用于中等交互元素的标题(如列表项标题、按钮组标题)
val titleMedium: TextStyle = TypographyTokens.TitleMedium,
// 小标题样式,用于次要交互元素的标题(如标签标题、小型控件标题)
val titleSmall: TextStyle = TypographyTokens.TitleSmall,

// 大正文样式,用于主要内容的长文本(如文章正文、详情描述),可读性优先
val bodyLarge: TextStyle = TypographyTokens.BodyLarge,
// 中正文样式,用于常规内容文本(如列表项描述、说明文字),最常用的正文样式
val bodyMedium: TextStyle = TypographyTokens.BodyMedium,
// 小正文样式,用于辅助性内容文本(如补充说明、注释),层级低于主要正文
val bodySmall: TextStyle = TypographyTokens.BodySmall,

// 大标签样式,用于重要标签或按钮文本(如主要按钮文字、状态标签)
val labelLarge: TextStyle = TypographyTokens.LabelLarge,
// 中标签样式,用于常规标签文本(如次要按钮文字、分类标签)
val labelMedium: TextStyle = TypographyTokens.LabelMedium,
// 小标签样式,用于辅助性标签文本(如小按钮文字、提示标签)
val labelSmall: TextStyle = TypographyTokens.LabelSmall,
) {}

3 核心亮点


  3.1 高效性、实时性


MaterialTheme 基于Compose"状态驱动机制",支持系统模式和系统色(Android 12+)动态切换,且无需重建界面或遍历View树,以最小成本实时自动切换效果。


  3.2 集中性


MaterialTheme 通过 colorScheme(配色)、typography(字体)、shapes(形状) 三个核心维度,将应用的视觉样式集中管理,避免了传统 XML 中样式分散在多个资源文件(colors.xml、styles.xml 等)的碎片化问题。


  3.3 灵活性、扩展性


MaterialTheme 并非固定样式模板,而是可高度定制的框架,满足不同场景下的各种需求:- 自定义主题扩展      除了默认colorScheme(配色)、typography(字体)、shapes(形状),还可通过CompositionLocal 扩展自定义主题属性。(下面会举例)- 多主题共存


假设在同一页面中存在两个Text,A Text跟随系统主题,B Text跟随自定义主题 。那么通过嵌套的方式局部的覆盖。(建议使用CompositionLocal 扩展实现,代码集中性和可读性更好。)


MaterialTheme(colorScheme = GlobalColors) {
// 全局主题
Column {
MaterialTheme(colorScheme = SpecialColors) {
Text("局部特殊主题文本") // 使用 SpecialColors
}
Text("全局主题文本") // 使用 GlobalColors
}
}

4 MaterialTheme 扩展使用


  上面我们已经介绍了MaterialTheme 提供的颜色、形状、字体模板,模板的目的满足全局绝大部分需求,但在实际开发中我们还存在切换系统模式/系统色时图片资源的变化,以及要求某些组件要求始终如一。


  那么我们就需要通过compositionLocalOf/staticCompositionLocalOf 和 扩展自定义主题属性了。


    4.1 效果展示



  • Image 随系统模式变化使用不同图片资源

  • Text 背景和文字不跟随系统模式变化


    4.2 定义 CompositionLocal实例



  • compositionLocalOf,创建一个可变的CompositionLocal实例,值发生变化时触发依赖组件重组。

  • staticCompositionLocalOf,创建一个不可变的 CompositionLocal实例,值发生变化时触发整个子树重组。

  • 值变化,是指对象引用(单纯的btnBackgroundColor/btnTitleColor 变化不会导致重组)

  • 整个子树重组,在使用staticCompositionLocalOf的CompositionLocalProvider内部的Content都会重组,且不会跳过重组。(如下示例是直接在Activity中使用,那么整个界面上的组件都会发生重组)


// 定义扩展主题
@Stable
class ExtendScheme(
btnBackgroundColor: Color,
btnTitleColor: Color
) {
/** 按钮背景颜色 */
var btnBackgroundColor by mutableStateOf(btnBackgroundColor)
internal set

/** 按钮标题颜色 */
var btnTitleColor by mutableStateOf(btnTitleColor)
internal set
}

// 扩展主题 --浅色
private val LightExtendScheme = ExtendScheme(
btnBackgroundColor = Color(0xFFF00FFF),
btnTitleColor = Color(0xFFFFFFFF),
)

// 扩展主题 --深色
private val DarkExtendScheme = ExtendScheme(
btnBackgroundColor = Color(0xFFF00FFF),
btnTitleColor = Color(0xFFFFFFFF),
)

// 定义一个存储 ExtendScheme 类型的CompositionLocal,默认值是浅色主题
val LocalExtendScheme = compositionLocalOf {
LightExtendScheme
}

// 定义主题资源
@Stable
class ResScheme(
imageRes: Int,
) {
var imageRes by mutableIntStateOf(imageRes)
}

// 图片资源--浅色
private val LightResScheme = ResScheme(
imageRes = R.drawable.ic_navi_home_light,
)

// 图片资源--深色
private val DarkResScheme = ResScheme(
imageRes = R.drawable.ic_navi_home_drak,
)

// 定义一个存储 ResScheme 类型的CompositionLocal,默认值是浅色资源
val LocalResScheme = compositionLocalOf {
LightResScheme
}

    4.3 CompositionLocalProvider 提供数据


CompositionLocalProvider是Compose中用于在Compoasable(可组合函数)树中传递数据的核心组件,允许你在某个层级定义“局部全局变量”,让其所有子组件(无论嵌套多深)都可以便捷访问,解决了:



  • 传统父组件 -> 子组件 ->孙组件这种层层传递的方式。

  • 有点类似于静态变量,但相对于静态变量的全局性和唯一性,CompositionLocalProvider作用范围仅限于其内部的所有子组件,所以可以理解为“局部全局变量”


// 定义应用的主题函数
@Composable
fun TestTheme(
// 是否使用深色主题,默认根据系统设置决定
darkTheme: Boolean = isSystemInDarkTheme()
,
// 是否使用动态颜色,Android 12+ 可用,默认为 false
dynamicColor: Boolean = false,
// 内容组件,使用 @Composable 函数类型
content: @Composable () -> Unit
) {
// 。。。。。 省略前面的

// 定义扩展主题
val extendScheme = if (darkTheme) {
DarkExtendScheme
} else {
LightExtendScheme
}
// 定义图片资源
val resScheme = if (darkTheme) {
DarkResScheme
} else {
LightResScheme
}

// 应用 Material Design 3 主题
MaterialTheme(
// 设置颜色方案
colorScheme = colorScheme,
// 设置排版样式
typography = Typography,
// 设置内容组件
content = {
// 提供LocalExtendScheme 和 LocalResScheme 数据,内部所有组件都可以访问
CompositionLocalProvider(
LocalExtendScheme provides extendScheme,
LocalResScheme provides resScheme
) {
content()
}
}
)
}

    4.4 使用


在Theme中根据需求配置完成后,无需再关心后续的系统模式/系统色变化了。


@Composable
fun Greeting1(modifier: Modifier = Modifier) {
Image(
modifier = Modifier
.padding(start = 300.dp, top = 100.dp)
.size(200.dp, 200.dp)
.background(Color.Gray),
// 使用图片资源
painter = painterResource(LocalResScheme.current.imageRes),
contentDescription = null,
)
Text(
text = "Hello Android!",
modifier = Modifier
.padding(start = 200.dp, top = 500.dp)
.size(300.dp, 200.dp)
//使用扩展颜色
.background(LocalExtendScheme.current.btnBackgroundColor),
color = LocalExtendScheme.current.btnTitleColor
)
}

5 参考资料



  • 基础组件、布局组件使用


  写在开头 | 你好 Compose


作者:用户06090525522
来源:juejin.cn/post/7559469775732981779
收起阅读 »

学习webhook与coze实现ai code review

AI代码审查工具github github.com/zhangjiadi2…测试可使用内网穿透工具将本地服务暴露到公网, 然后配置对应webhook. 日志目前只保留发送请求的message以及ai审查报告 .ai建议使用coze, 直接使用gpt相...
继续阅读 »

AI代码审查工具

github github.com/zhangjiadi2…

测试可使用内网穿透工具将本地服务暴露到公网, 然后配置对应webhook. 日志目前只保留发送请求的message以及ai审查报告 .

ai建议使用coze, 直接使用gpt相关接口, 暂时每次都得携带大量文本 .

项目概述

这是一个基于Node.js开发的智能代码审查工具(demo)

核心特性

🚀 多AI服务支持

  • 硅基流动AI: 基于深度学习的代码分析引擎
  • Coze智能体: 专业的代码审查AI助手
  • 动态切换: 支持运行时切换不同的AI服务

🔗 无缝集成

  • GitHub Webhook: 自动监听代码推送事件
  • 实时处理: 提交后立即触发审查流程
  • 零配置部署: 简单的环境变量配置即可运行

📊 智能分析

  • 代码质量评估: 全面分析代码结构、性能和安全性
  • 最佳实践建议: 基于行业标准提供改进建议
  • 多语言支持: 支持JavaScript、Python、Java等主流编程语言

💾 结果持久化

  • 本地存储: 审查结果自动保存为结构化文本文件
  • 历史追踪: 完整的审查历史记录
  • 便于查阅: 清晰的文件命名和内容格式

技术架构

系统架构图

GitHub Repository
(Webhook)
Express Server

Webhook Handler

GitHub Service ←→ AI Service Factory

Diff Analysis [SiliconFlow | Coze]

File Storage ←── Review Results

核心组件

1. Web服务层 (src/index.js)
  • 基于Express.js的HTTP服务器
  • 提供健康检查、日志查看等管理接口
  • 优雅的错误处理和请求日志
2. Webhook处理器 (src/routes/webhook.js)
  • GitHub事件监听和处理
  • 提交数据解析和验证
  • 异步任务调度
3. GitHub服务 (src/services/github.js)
  • GitHub API集成
  • 代码差异获取
  • 智能文件过滤(仅处理代码文件)
4. AI服务工厂 (src/services/ai/)
  • 基础抽象类 (base.js): 定义AI服务通用接口
  • 硅基流动服务 (siliconflow.js): 集成硅基流动AI API
  • Coze服务 (coze.js): 集成Coze智能体平台
  • 服务工厂 (index.js): 动态服务选择和管理

工作流程

1. 代码提交触发

sequenceDiagram
Developer->>GitHub: git push
GitHub->>AI Review Tool: Webhook Event
AI Review Tool->>GitHub API: Get Commit Diff
GitHub API-->>AI Review Tool: Return Diff Data

2. AI分析处理

sequenceDiagram
AI Review Tool->>AI Service: Send Code Diff
AI Service->>AI Provider: API Request
AI Provider-->>AI Service: Analysis Result
AI Service-->>AI Review Tool: Formatted Review

3. 结果存储

sequenceDiagram
AI Review Tool->>File System: Save Review
AI Review Tool->>Logs: Record Process
AI Review Tool-->>GitHub: Response OK

安装与配置

环境要求

  • Node.js 14.0+
  • npm 6.0+

快速开始

  1. 克隆项目
git clone 
cd ai-code-review
  1. 安装依赖
npm install
  1. 环境配置
cp .env.example .env
# 编辑.env文件,配置必要的API密钥
  1. 启动服务
# 开发模式
npm run dev

# 生产模式
npm start

配置说明

基础配置
# 服务端口
PORT=3000

# 环境类型
NODE_ENV=development
GitHub集成
# GitHub访问令牌(可选,用于私有仓库)
GITHUB_TOKEN_AI=your_github_token
AI服务配置
# 当前使用的AI服务类型
AI_SERVICE_TYPE=coze

# 硅基流动AI配置
SILICONFLOW_API_KEY=your_siliconflow_key
SILICONFLOW_MODEL=deepseek-chat

# Coze智能体配置
COZE_API_URL=https://api.coze.cn/v3/chat
COZE_API_KEY=your_coze_key
COZE_BOT_ID=your_bot_id

使用指南

GitHub Webhook配置

  1. 进入GitHub仓库设置页面
  2. 选择"Webhooks" → "Add webhook"
  3. 配置参数:
  4. 保存配置

审查结果查看

审查结果自动保存在reviews/目录下,文件命名格式:

review_[service]_[commit_id]_[timestamp].txt

示例文件内容:

代码审查报告 (coze)
==========================================


提交ID: abc123def456
提交信息: 修复用户登录bug
作者: 张三
审查时间: 2024-01-01T10:00:00.000Z

详细建议:
------------------------------------------


1. 安全性建议:
- 建议在密码验证前添加输入验证
- 考虑使用bcrypt进行密码哈希

2. 性能优化:
- 数据库查询可以添加索引优化
- 建议使用连接池管理数据库连接

3. 代码规范:
- 变量命名建议使用驼峰命名法
- 建议添加必要的错误处理

项目结构

ai-code-review/
├── src/ # 源代码目录
│ ├── index.js # 应用入口文件
│ ├── routes/ # 路由处理
│ │ ├── webhook.js # Webhook事件处理
│ │ ├── debug.js # 调试接口
│ │ └── logs.js # 日志查看接口
│ ├── services/ # 核心服务
│ │ ├── ai/ # AI服务模块
│ │ │ ├── base.js # AI服务基类
│ │ │ ├── index.js # 服务工厂
│ │ │ ├── siliconflow.js # 硅基流动AI服务
│ │ │ └── coze.js # Coze智能体服务
│ │ ├── github.js # GitHub API服务
│ │ └── logger.js # 日志服务
│ ├── middleware/ # 中间件(预留)
│ ├── utils/ # 工具函数(预留)
│ └── public/ # 静态资源
├── reviews/ # 审查结果存储
├── messages/ # AI请求消息存储
├── logs/ # 系统日志
├── test/ # 测试文件
├── package.json # 项目配置
├── .env # 环境变量
└── README.md # 项目说明

开发特性

代码质量保障

  • ESLint: 代码风格检查
  • 错误处理: 完善的异常捕获机制
  • 日志系统: 详细的操作日志记录

扩展性设计

  • 插件化架构: 易于添加新的AI服务
  • 配置驱动: 通过环境变量灵活配置
  • 模块化设计: 清晰的代码组织结构

性能优化

  • 异步处理: 非阻塞的事件处理
  • 智能过滤: 仅处理代码文件,忽略配置和资源文件
  • 错误恢复: 优雅的错误处理,避免服务中断

最佳实践

安全建议

  • 使用HTTPS部署生产环境
  • 定期轮换API密钥
  • 限制GitHub Token权限范围
  • 配置防火墙规则

性能优化

  • 定期清理历史文件
  • 监控API调用频率
  • 配置适当的超时时间
  • 使用负载均衡(高并发场景)


作者:只想过平凡生活的迪迪
来源:juejin.cn/post/7530106539467669544
收起阅读 »

从“写循环”到“写思想”:Java Stream 流的高级实战与底层原理剖析

引言 在实际开发中,很多工程师依然停留在“用 for 循环遍历集合”的思维模式。但在大型项目、复杂业务中,这种写法往往显得冗余、难以扩展,也不符合函数式编程的趋势。 Stream API 的出现,不只是“简化集合遍历”,而是把 声明式编程思想 带入了 Java...
继续阅读 »

引言


在实际开发中,很多工程师依然停留在“用 for 循环遍历集合”的思维模式。但在大型项目、复杂业务中,这种写法往往显得冗余、难以扩展,也不符合函数式编程的趋势。

Stream API 的出现,不只是“简化集合遍历”,而是把 声明式编程思想 带入了 Java,使我们能以一种更优雅、更高效、更可扩展的方式处理集合与数据流。


如果你还把 Stream 仅仅理解为 list.stream().map(...).collect(...),那就大错特错了。本文将从 高级用法、底层原理、业务实践、性能优化 四个维度,带你重新认识 Stream —— 让它真正成为你架构设计和代码表达的利器。


一、为什么要用 Stream?


在真实业务场景中,Stream 的价值不仅仅体现在“更少的代码量”,而在于:



  1. 声明式语义 —— 写“我要做什么”,而不是“怎么做”。


// 传统方式
List<String> result = new ArrayList<>();
for (User u : users) {
if (u.getAge() > 18) {
result.add(u.getName());
}
}

// Stream 写法:表达意图更清晰
List<String> result = users.stream()
.filter(u -> u.getAge() > 18)
.map(User::getName)
.toList();

后者的代码阅读体验更接近“业务规则”,而非“算法步骤”。



  • 可扩展性 —— 同样的链式调用,可以无缝切换到 并行流(parallelStream)以提升性能,而无需修改核心逻辑。

  • 契合函数式编程趋势 —— 在 Java 8 引入 Lambda 后,Stream 彻底释放了函数式编程的潜力。


二、Stream 的核心思想


Stream API 的设计核心可以用一句话概括:


把数据操作抽象成流水线,每一步都是一个中间操作,最终由终止操作触发执行



  • 数据源(Source) :集合、数组、I/O、生成器等。

  • 中间操作(Intermediate Operations)filtermapflatMapdistinctsorted …,返回一个新的 Stream(惰性求值)。

  • 终止操作(Terminal Operations)collectforEachreducecount …,触发实际计算。


关键点:Stream 是惰性的。中间操作不会立即执行,直到遇到终止操作才会真正运行。


三、高级用法与最佳实践


1. 多级分组与统计


真实业务中,常见的场景是“按条件分组统计”。


// 按部门分组,并统计每个部门的人数
Map<String, Long> groupByDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));

// 多级分组:按部门 -> 按职位
Map<String, Map<String, List<Employee>>> group = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getTitle)));

2. flatMap 的威力


flatMap 可以把多层集合打平成单层流。


// 一个学生对应多个课程,如何获取所有课程的去重列表?
List<String> courses = students.stream()
.map(Student::getCourses) // Stream<List<String>>
.flatMap(List::stream) // Stream<String>
.distinct()
.toList();

3. reduce 高阶聚合


Stream 的 reduce 方法提供了更灵活的聚合方式。


// 求所有订单的总金额
BigDecimal total = orders.stream()
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

相比 Collectors.summingInt 等方法,reduce 更加灵活,适合需要自定义聚合逻辑的场景。


4. 结合 Optional 优雅处理空值


Stream 与 Optional 配合,可以消除 if-null 的丑陋写法。


// 找到第一个满足条件的用户
Optional<User> user = users.stream()
.filter(u -> u.getAge() > 30)
.findFirst();

与传统的 null 判断相比,这种写法更安全、更符合函数式语义。


5. 并行流与 ForkJoinPool


只需一行代码,就能让 Stream 自动并行处理


long count = bigList.parallelStream()
.filter(item -> isValid(item))
.count();

注意点



  • 并行流基于 ForkJoinPool,默认线程数 = CPU 核心数。

  • 不适合小数据量,启动线程开销可能大于收益。

  • 不适合有共享资源的场景(容易产生锁竞争)。


四、Stream 的底层原理


理解底层机制,才能在性能和架构上做出正确决策。



  1. 流水线模型(Pipeline Model)



    • 每个中间操作都返回一个 Stream,但实际上内部是一个 Pipeline

    • 只有终止操作才会触发数据逐步流经整个 pipeline。



  2. 内部迭代(Internal Iteration)



    • 相比外部迭代(for 循环),Stream 将迭代逻辑交给框架本身,从而更容易做优化(如并行)。



  3. 短路操作(Short-circuiting)



    • anyMatchfindFirst 等操作可以在满足条件时立刻返回,避免不必要的计算。



  4. 内存与性能



    • 惰性求值减少不必要的计算。

    • 但过度链式调用可能带来额外开销(对象创建、函数调用栈)。




五、业务场景中的最佳实践


1. 日志分析系统


日志按时间、级别分组统计:


Map<LogLevel, Long> logCount = logs.stream()
.filter(log -> log.getTimestamp().isAfter(start))
.collect(Collectors.groupingBy(Log::getLevel, Collectors.counting()));

2. 电商系统订单处理


对订单进行聚合,计算 GMV(成交总额):


BigDecimal gmv = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.FINISHED)
.map(Order::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);

3. 权限系统多对多关系处理


用户-角色-权限的映射去重:


Set<String> permissions = users.stream()
.map(User::getRoles)
.flatMap(List::stream)
.map(Role::getPermissions)
.flatMap(List::stream)
.collect(Collectors.toSet());

六、性能优化与陷阱



  1. 避免在 Stream 中修改外部变量


List<String> result = new ArrayList<>();
list.stream().forEach(e -> result.add(e)); //违反函数式编程

应该用 collect



  • 适度使用并行流



    • 小集合别用并行流。

    • 线程池可通过 ForkJoinPool.commonPool() 自定义。



  • 避免链式调用过长

    虽然优雅,但可读性会下降,必要时拆分。

  • Stream 不是万能的



    • 对于简单循环,普通 for 循环更直观。

    • 对性能敏感的底层操作(如数组拷贝),直接用原生循环更高效。




总结


Stream 并不是一个“语法糖”,而是 Java 向函数式编程迈进的重要里程碑

它让我们能以声明式、可扩展、可并行的方式处理数据流,提升代码表达力和业务抽象能力。


对于中高级开发工程师来说,Stream 的价值在于:



  • 提升业务逻辑的可读性和可维护性

  • 利用底层并行能力提升性能

  • 契合函数式思维,帮助团队写出更现代化的 Java 代码


未来的你,写业务逻辑时,应该少考虑“怎么遍历”,多去思考“我要表达的业务规则是什么”。


作者:就是帅我不改
来源:juejin.cn/post/7538829865351036967
收起阅读 »

这样代码命名,总不会被同事蛐蛐了吧

1. 引言 ....又好笑,又不耐烦,懒懒的答他道,“谁要你教,不是草头底下一个来回的回字么?”孔乙己显出极高兴的样子,将两个指头的长指甲敲着柜台,点头说,“对呀对呀!……回字有四样写法,你知道么?”我愈不耐烦了,努着嘴走远。孔乙己刚用指甲蘸了酒,想在柜上写...
继续阅读 »

1. 引言



....又好笑,又不耐烦,懒懒的答他道,“谁要你教,不是草头底下一个来回的回字么?”孔乙己显出极高兴的样子,将两个指头的长指甲敲着柜台,点头说,“对呀对呀!……回字有四样写法,你知道么?”我愈不耐烦了,努着嘴走远。孔乙己刚用指甲蘸了酒,想在柜上写字,见我毫不热心,便又叹一口气,显出极惋惜的样子



针对于同一个代码变量或者函数方法,张三可能认为可以叫 xxx,李四可能摇头说 不不不,得叫 yyyy ,好的命名让人如沐春风,原来是这个意思;坏的代码命名,同事可能会眉头紧锁,然后送你两斤熏鸡骨头让你炖汤


比如隔壁小组新来的一个同事,对字符串命名就用 s,对于布尔值的命名就用 b,然后他的主管说他的变量名起的跟他人一样。如何做到信雅达的命名,让同事不会再背后蛐蛐,我是这样想的。


2. 代码整洁之道


2.1 团队规范


“我在上家公司都是这样命名的,在这里我也要这样命名”


小组里张三给 Service 起的名字叫 UserService 实现类是 UserServiceImpl;小组里李四给 Service 起的名字叫 CustomerService 实现类 CustomerServiceImpl


你跳出来出来说,统统不对,接口需要区分对待 得叫 IUserService 和 ICustomerService


但是组里成员都不习惯往接口类加个 I;或许这就是 E 人编码吧,不能写 I(我承认这个梗有点烂)


双拳难敌四手,亲,这边建议你按照 UserService 和 CustomerService 起名


这只是个简单的例子,还有就是你认为 4 就是 for,2 就是 to,如果小组内的成员表示认可你的想法,那你就尽管大胆的使用,但是小组成员要是没有这一点习惯,建议还是老老实实 for 和 to,毕竟你没有一票否决权


诸如此类的还有 request -> req、response -> resp 等


以下所有的代码命名建议都不能打破团队规范这一条大原则


2.2 统一业务词汇


在各行各业中,基于业务属性,我们都有一些专业术语,对于专业术语的命名往往在设计领域模型的时候已经确定下来,建议有一份业务词汇来规范团队同学命名,或者以数据库字段为准


比如在保险行业中,我们有保费(premium)、保单(policy)、被保人(assured)等,针对于这些业务词汇,务必需要统一。被保人就是 assured 不是 Insured Person


2.2 名副其实


“语义一定要清晰,不然后续接手的人根本看不懂,我的这个函数名是用来对订单进行删除操作,然后进行 MQ 消息推送的,我准备给他起名为 deleteOrderByCustomerIdAndSendMqMessage”


对,函数名很长很清晰,虽然我的屏幕很宽,但是针对于这样的命名,我觉得不可取,函数名和函数一样应该尽量短小,当你的命名极其长的时候你需要考虑简化你的命名,或者 考虑你的函数是否遵循到了单一职责。


bad😭
 deleteOrderByCustomerIdAndSendMqMessage(String userId)

good🤭
deleteOrder(String userId)
sendMq()

我们在做阅读理解的时候,需要结合上下文来作答,同样,我们的命名需要让下一个做阅读理解的人感受到我们的上下文含义。在我们删除订单的时候,假设我们需要用到订单的 ID,那么我们的命名需要是 orderId = 123,而不是 id = 123


bad😭 这个 id 指代的是什么,订单ID 还是用户 ID 
id = 123
good🤭
deleteOrder(String userId)
orderId = 123

人靠衣装马靠鞍,变量类型需“平安”,我们在起名的时候需要对的起自己的名字


bad😭  tm的喵,我以为是个 list 
String idList = "1,2,3"
good🤭
List<String> idList = ImmutabList.of("1", "2", "3")

默认我的同事的英文水平只有四级,我们变量命名的时候,尽量取一些大众化的词汇,可以四级词汇,切莫六级词汇


bad😭  
actiivityOrchestrater
good🤭 活动策划人
actiivityPlanner

普通函数、方法命名以动词开头


bad😭  
messageSend
good🤭
sendMessage

减少介词链接,考虑使用 形容词+名词


productListForSpecialOffer -> specialOfferProductList
productListForNewArrival -> newArrivalProductList
productListFromHenan -> henanProductList
productListWithGiftBox -> withGiftBoxProductList \ giftBoxedProductList
productListWithoutGiftBox -> withoutGiftBoxProductList \ noGiftBoxProductList \ unGiftBoxedProductList

消除无意义的前后缀单词: userInfo、userData,info 和 data 的含义过于宽泛,没有实质性意义所以我们可以不用写。或者诸如在 UserService 类中,我们可以可以尝试将 selectUserList 更换为 selectList,因为我们调用的时候,上下文一定是 userService.selectList,阅读者是可以感受到我们的语义的


userInfo -> user
userService.selectUserList -> userService.selectList

做有意义的方法名的区分:在我刚入职的时候,有一个 OrderService 中,存在 4个方法,enableOrder、enableOrderV2、enableOrderV3、enableOrderV4,我问组里的同事,有什么区别,他们告诉我,现在各个外部服务用的不同,不知道有啥区别。所以为了避免给类似我这样的菜鸟产生歧义,建议在方法起名的时候做好区分,以免埋坑


3. 常见开发词汇


image-20241214214235702.png


作者:issue
来源:juejin.cn/post/7449083760618684467
收起阅读 »

通信的三种基本模式:单工、半双工与全双工

在数据通信与网络技术中,信道的“方向性”是一个基础而核心的概念。它定义了信息在通信双方之间流动的方向与方式。根据其特性,我们通常将其归纳为三种基本模式:单工、半双工和全双工。清晰理解这三种模式,是掌握众多通信协议与网络技术的基石。 一、单工通信 单工通信代表...
继续阅读 »

在数据通信与网络技术中,信道的“方向性”是一个基础而核心的概念。它定义了信息在通信双方之间流动的方向与方式。根据其特性,我们通常将其归纳为三种基本模式:单工半双工全双工。清晰理解这三种模式,是掌握众多通信协议与网络技术的基石。


一、单工通信


64fdea5289356c4c991d53b5017419f1.png


单工通信代表了最单一、最直接的数据流向。



  • 定义:数据只能在一个方向上传输,通信的一方固定为发送端,另一方则固定为接收端。

  • 核心特征方向不可改变。就像一条单行道,数据流只有一个固定的方向。

  • 经典比喻

    • 广播电台:电台负责发送信号,广大听众的收音机只能接收信号,无法通过收音机向电台发送数据。

    • 电视信号传输:电视台到家庭电视的信号传输。

    • 键盘到计算机(在传统概念中):数据从键盘单向传入计算机。




单工通信模式简单、成本低,但交互性为零,无法实现双向信息交流。


二、半双工通信


ddab1e4ef9c9511ab4ca99ca3fd197e3.png
半双工通信允许了双向交流,但增加了“轮流”的规则。



  • 定义:数据可以在两个方向上传输,但在任一时刻,只能有一个方向在进行传输。它需要一种“切换”机制来改变数据传输的方向。

  • 核心特征双向交替,不能同时

  • 经典比喻

    • 对讲机:一方需要按下“通话键”说话,说完后必须说“完毕”并松开按键,切换到接收状态,才能听到对方的回复。双方不能同时讲话。

    • 独木桥:同一时间只能允许一个人从一个方向通过。




半双工的局限性:
由于其交替通信的本质,半双工存在几个固有缺陷:



  1. 效率较低:存在信道空闲和状态切换的时间开销,总吞吐量低。

  2. 延迟较高:发送方必须等待信道空闲才能发送,接收方必须等待发送方完毕才能接收。

  3. 可能发生碰撞:在共享信道中,若多个设备同时开始发送,会导致数据冲突,必须重传,进一步降低效率。

  4. 需要冲突管理:必须引入如CSMA/CD(载波侦听多路访问/冲突检测)等协议来管理信道访问,增加了系统复杂度。


三、全双工通信


87e4c78ee289efb3de4373d473973cd7.png


全双工通信实现了最自然、最高效的双向交互。



  • 定义:数据可以在两个方向上同时进行传输。

  • 核心特征同时双向传输

  • 经典比喻

    • 电话通话:双方可以同时说话和聆听,交流过程自然流畅,无需等待。

    • 双向多车道公路:两个方向的车流拥有各自独立的车道,可以同时、高速、互不干扰地行驶。




技术实现:全双工通常需要两条独立的物理信道(如网线中的两对线),或通过频分复用等技术在一条信道上逻辑地划分出上行和下行通道。其最大优势在于彻底避免了半双工中固有的碰撞问题。


三种模式对比总结


特性维度单工半双工全双工
数据流向仅单向双向,但交替进行双向,同时进行
经典比喻广播对讲机电话
信道占用一条单向信道一条共享信道两条独立信道或等效技术
效率低(无交互)较低
交互性有,但不流畅有,且自然流畅
数据碰撞可能发生不可能发生
典型应用广播、电视早期以太网、对讲机现代以太网、电话、视频会议

结论


从单工的“只读”模式,到半双工的“轮流对话”,再到全双工的“自由交谈”,通信模式的演进体现了人们对更高效率和更自然交互的不懈追求。全双工凭借其高吞吐量、低延迟和无碰撞的特性,已成为当今主流有线与无线网络(如交换式以太网、4G/5G移动通信)的标配。而半双工和单工则在物联网、传感器网络、广播等特定应用场景中,因其成本或功能需求,依然保有一席之地。理解这三种基础模式,是步入更复杂通信世界的第一步。


作者:oioihoii
来源:juejin.cn/post/7563108340538507318
收起阅读 »

【前端效率工具】:告别右键另存,不到 50 行代码一键批量下载网页图片

web
前端还原页面你肯定干过吧?像仿 xxx 首页那种。收素材时最烦的就是一张张存图,慢不说还老漏。跟我用 10 分钟做个chrome小插件,点一下,整页图片全下到本地先看效果:在素材网站一键批量保存所有图片废话不多说,直接上手!项目结构image-download...
继续阅读 »

前端还原页面你肯定干过吧?像仿 xxx 首页那种。收素材时最烦的就是一张张存图,慢不说还老漏。

跟我用 10 分钟做个chrome小插件,点一下,整页图片全下到本地

先看效果:在素材网站一键批量保存所有图片

批量下载.gif

废话不多说,直接上手!

项目结构

image-downloader-extension
├── manifest.json # 扩展的"身-份-证"
└── background.js # 插件后台脚本
  1. 创建文件夹 image-downloader-extension

  2. 创建manifest.json文件

这个文件是插件的身-份-证,告诉浏览器你的插件是谁、能干啥。

{
"manifest_version": 3,
"name": "我的下载插件",
"version": "1.0.0",
"permissions": ["contextMenus", "downloads", "scripting"],
"host_permissions": [""],
"background": {
"service_worker": "background.js"
}
}

关键点解读:

字段说明
manifest_version: 3使用最新的 Manifest V3 扩展规范
name插件名称
version插件版本号
permissions申请权限(contextMenus 创建右键菜单,downloads下载)
  1. 创建background.js文件

background.js后台脚本负责创建并响应右键菜单等事件来下载页面图片

// 1. 插件安装时创建右键菜单
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'downloadAllImages', // 菜单唯一标识
title: '我要下载所有图片', // 菜单显示的文字
contexts: ['page'], // 在页面任意位置右键时显示
});
});

// 2. 监听右键菜单点击事件
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'downloadAllImages') {
// 使用 scripting API 在当前页面执行脚本获取所有图片
chrome.scripting.executeScript(
{
target: { tabId: tab.id },
func: getImagesFromPage,
},
(results) => {
// 获取执行结果
if (!results || !results[0]?.result || results[0].result.length === 0) {
console.log('未找到图片');
return;
}
const images = results[0].result;
// 批量下载图片
images.forEach((url, index) => {
setTimeout(() => {
chrome.downloads.download({
url: url,
filename: `images/image_${index + 1}.jpg`, // 保存路径
saveAs: false, // 不弹出保存对话框
});
}, index * 500); // 每张图片间隔 500ms,避免浏览器限制
});
}
);
}
});

// 在页面中执行的函数,用于获取所有图片URL
function getImagesFromPage() {
const images = Array.from(document.images)
.map((img) => img.src)
.filter((src) => src.startsWith('http'));

return images;
}

API 文档速查

4. 加载插件到浏览器

接下来我们将插件加载到浏览器中

插件加载.gif

步骤:
4.1 打开扩展管理页面 在 Chrome 地址栏输入 chrome://extensions/ 并回车
4.2 开启开发者模式
4.3 点击 “加载未打包的扩展程序”

选择刚刚创建的image-downloader-extension文件夹进行加载

4.4 插件加载成功

你会看到插件出现在列表中

企业微信截图_17601472945027.png

至此,我们的下载插件就搞完了,是不是非常容易?

测试(验证功能)

接下来我们随便打开一个网站,点击鼠标右键,就会发现右键菜单多了一个选项

右键选项.png

点击“我要下载所有图片” 即可实现我们的需求了

调试(查看 background.js日志与断点)

如下图:点击插件的 Service Worker 入口,会弹出调试面板。

在该面板中你可以

  • 实时查看 background.js 的 console日志输出;
  • 在代码中设置断点调试以排查问题。

serviceWorker调试.gif

总结

这一次带你用一个小巧的 Chrome 插件,一键把当前网页的所有图片下载下来,希望对你有所帮助


作者:不一样的少年_
来源:juejin.cn/post/7559124639323242506
收起阅读 »

Token已过期,我是如何实现无感刷新Token的?

web
我们来想象一个场景:你正在一个电商网站上,精心挑选了半小时的商品,填好了复杂的收货地址,满心欢喜地点击提交订单 Button。 突然,页面Duang🎈地一下,跳转到了登录页,并提示你:“登录状态已过期,请重新登录”。 那一刻,你的内心是什么感受?我想大概率是崩...
继续阅读 »

image.png


我们来想象一个场景:你正在一个电商网站上,精心挑选了半小时的商品,填好了复杂的收货地址,满心欢喜地点击提交订单 Button。


突然,页面Duang🎈地一下,跳转到了登录页,并提示你:“登录状态已过期,请重新登录”。


那一刻,你的内心是什么感受?我想大概率是崩溃的,并且想把这个网站拉进黑名单。


这就是一个典型的、因为Token过期处理不当,而导致的灾难级用户体验。作为一个负责任的开发者,这是我们绝对不能接受的。


今天就聊聊,我们团队是如何通过请求拦截队列控制,来实现无感刷新Token的。让用户即使在Token过期的情况下,也能无缝地继续操作,就好像什么都没发生过一样。




先讲基础知识


为什么需要两个Token?


要实现无感刷新,我们首先需要后端同学的配合,采用双Token的认证机制。



  1. accessToken: 这是我们每次请求业务接口时,都需要在请求头里带上的令牌。它的特点是生命周期短(比如1小时),因为暴露的风险更高。

  2. refreshToken: 它的唯一作用,就是用来获取一个新的accessToken。它的特点是生命周期长(比如7天),并且需要被安全地存储(比如HttpOnly的Cookie里)。


流程是这样的:用户登录成功后,后端会同时返回accessTokenrefreshToken。前端将accessToken存在内存(或LocalStorage)里,然后在后续的请求中,通过refreshToken来刷新。
image.png




解决思路,利用axios的请求拦截器


我们整个方案的核心,是利用axios(或其他HTTP请求库)提供的请求拦截器(Interceptor) 。它就像一个哨兵,可以在请求发送前和响应返回后,对请求进行拦截和改造。


我们的目标是:



  1. 响应拦截器里,捕获到后端返回的accessToken已过期的错误(通常是401状态码)。

  2. 当捕获到这个错误时,暂停所有后续的API请求。

  3. 使用refreshToken,悄悄地在后台发起一个获取新accessToken的请求。

  4. 拿到新的accessToken后,更新我们本地存储的Token

  5. 最后,把之前失败的请求和被暂停的请求,用新的Token重新发送出去。


这个过程对用户来说,是完全透明的。他们最多只会感觉到某一次API请求,比平时慢了一点点。




具体怎么实现?


下面是我们团队在项目中,实际使用的axios拦截器伪代码。


import axios from 'axios';

// 创建一个新的axios实例
const api = axios.create({
baseURL: '/api',
timeout: 5000,
});

// ------------------- 请求拦截器 -------------------
api.interceptors.request.use(config => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
}, error => {
return Promise.reject(error);
});


// ------------------- 响应拦截器 -------------------

// 用于标记是否正在刷新token
let isRefreshing = false;
// 用于存储因为token过期而被挂起的请求
let requestsQueue = [];

api.interceptors.response.use(
response => {
return response;
},
async error => {
const { config, response } = error;

// 如果返回的HTTP状态码是401,说明access_token过期了
if (response && response.status === 401) {

// 如果当前没有在刷新token,那么我们就去刷新token
if (!isRefreshing) {
isRefreshing = true;

try {
// 调用刷新token的接口
const { data } = await axios.post('/refresh-token', {
refreshToken: localStorage.getItem('refreshToken')
});

const newAccessToken = data.accessToken;
localStorage.setItem('accessToken', newAccessToken);

// token刷新成功后,重新执行所有被挂起的请求
requestsQueue.forEach(cb => cb(newAccessToken));
// 清空队列
requestsQueue = [];

// 把本次失败的请求也重新执行一次
config.headers.Authorization = `Bearer ${newAccessToken}`;
return api(config);

} catch (refreshError) {
// 如果刷新token也失败了,说明refreshToken也过期了
// 此时只能清空本地存储,跳转到登录页
console.error('Refresh token failed:', refreshError);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
// 如果当前正在刷新token,就把这次失败的请求,存储到队列里
// 返回一个pending的Promise,等token刷新后再去执行
return new Promise((resolve) => {
requestsQueue.push((newAccessToken) => {
config.headers.Authorization = `Bearer ${newAccessToken}`;
resolve(api(config));
});
});
}
}

return Promise.reject(error);
}
);

export default api;

这段代码的关键点,也是面试时最能体现你思考深度的地方:



  1. isRefreshing 状态锁:


    这是为了解决并发问题。想象一下,如果一个页面同时发起了3个API请求,而accessToken刚好过期,这3个请求会同时收到401。如果没有isRefreshing这个锁,它们会同时去调用/refresh-token接口,发起3次刷新请求,这是完全没有必要的浪费,甚至可能因为并发问题导致后端逻辑出错。


    有了这个锁,只有第一个收到401的请求,会真正去执行刷新逻辑。


  2. requestsQueue 请求队列:


    当第一个请求正在刷新Token时(isRefreshing = true),后面那2个收到401的请求怎么办?我们不能直接抛弃它们。正确的做法,是把它们的resolve函数推进一个队列(requestsQueue)里,暂时挂起。


    等第一个请求成功拿到新的accessToken后,再遍历这个队列,把所有被挂起的请求,用新的Token重新执行一遍。





无感刷新Token这个功能,用户成功的时候,是感知不到它的存在的。


但恰恰是这种无感的细节,区分出了一个能用的应用和一个好用的应用。


因为一个资深的开发者,他不仅关心功能的实现,更应该关心用户体验整个系统的健壮性


希望这一套解决思路,能对你有所帮助🤞😁。


作者:ErpanOmer
来源:juejin.cn/post/7550943000689950774
收起阅读 »

大数据-133 ClickHouse 概念与基础|为什么快?列式 + 向量化 + MergeTree 对比

TL;DR 场景:要做高并发低延迟 OLAP,且不想上整套 Hadoop/湖仓。 结论:ClickHouse 的核心在列式+向量化+MergeTree+近似统计;适合即席分析与近实时写入,不适合强事务与高频行级更新。 产出:选型决策表 + 分区/排序键速查卡 ...
继续阅读 »

TL;DR


场景:要做高并发低延迟 OLAP,且不想上整套 Hadoop/湖仓。
结论:ClickHouse 的核心在列式+向量化+MergeTree+近似统计;适合即席分析与近实时写入,不适合强事务与高频行级更新。
产出:选型决策表 + 分区/排序键速查卡 + 5 条查询模板(安装/集群放到下一章)。


大数据-133 ClickHouse 概念与基础|为什么快?列式 + 向量化 + MergeTree 对比


ClickHouse官网


简要概述


ClickHouse 是一个快速开源的OLAP数据库管理系统,它是面向列的,允许使用SQL查询实时生成分析报告。


随着物联网IOT时代的来临,IOT设备感知和报警存储数据越来越大,有用的价值数据需要数据分析师去分析。大数据分析成了非常重要的环节,开源也为大数据分析工程师提供了十分丰富的工具,但这也增加了开发者选择适合的工具的难度,尤其是新入行的开发者来说。


框架的多样化和复杂度成了很大的难题,例如:Kafka、HDFS、Spark、Hive等等组合才能产生最后的分析结果,把各种开源框架、工具、库、平台人工整合到一起所需的工作之复杂,是大数据领域开发和数据分析师常有的抱怨之一,也就是他们支持大数据分析简化和统一化的首要原因。


数仓的整体架构设计图
从业务维度来分析,用户需求会反向促使技术发展。


简要选型


需求/约束适合不适合
高并发、低延迟 OLAP
重事务/强一致 OLTP
近实时写入、即席分析
频繁行级更新/删除⚠️(有 mutations 但代价高)


  • 需要强事务/OLTP → 不是 CH

  • OLAP + 近实时 + 自建机房成本敏感 → CH 优先

  • 只做离线、成本不敏感且现有湖仓成熟 → SparkSQL/Trino 也行

  • 预计算立方体 + 报表固化 → Druid/Kylin 也可


OLTP


OLTP:Online Transaction Processing:联机事务处理过程。


应用场景



  • ERP:Enterprise Resource Planning 企业资源计划

  • CRM:Customer Relationship Management 客户关系管理


流程审批、数据录入、填报等


具体特点


线下工作线上化,数据保存在各自的系统中,互不相同(数据孤岛)


OLAP


OLAP:On-Line Analytical Processing:联机分析系统


分析报表、分析决策等。


应用场景


方案1:数仓


Hadoop HDFS 整体架构
如上图所示,数据实时写入HBase,实时的数据更新也在HBase完成,为了应对OLAP需求,我们定时(通常是T+1或者T+H)将HBase数据写成静态的文件(如:Parquet)导入到 OLAP引擎(如HDFS,比较常见的是Impala操作Hive)。这一架构又能满足随机读写,又可以支持OLAP分析的场景,但是有如下缺点:



  • 架构复杂:从架构上看,数据在HBase、消息队列、HDFS间流转,涉及到的环节过多,运维成本也很高,并且每个环节需要保证高可用,都需要维护多个副本,存储空间也有一定的浪费。最后数据在多个系统上,对数据安全策略、监控都提出了挑战。

  • 时效性低:数据从HBase导出静态文件是周期性的,一般这个周期一天(或者一小时),有时效性上不是很高。

  • 难以应对后续的更新:真实场景中,总会有数据是延迟到达的,如果这些数据之前已经从HBase导出到HDFS,新到的变更数据更难以处理了,一个方案是把原有数据应用上新的变更后重写一遍,但这代价又很高。


方案2:ClickHouse、Kudu


实现方案2就是 ClickHouse、Kudu


发展历史
Yandex在2016年6月15日开源了一个数据分析数据库,叫做ClickHouse,这对保守的俄罗斯人来说是个特大事件。更让人惊讶的是,这个列式数据库的跑分要超过很多流行的商业MPP数据库软件,例如Vertica。如果你没有Vertica,那你一定听过Michael Stonebraker,2014年图灵奖的获得者,PostgreSQL和Ingres发明者(Sybase和SQL Server都是继承Ingres而来的),Paradigm4和SciDB的创办者。Micheal StoneBraker于2005年创办的Vertica公司,后来该公司被HP收购,HP Vertica成为MPP列式存储商业数据库的高性能代表,Facebook就购买了Vertica数据用于用户行为分析。


ClickHouse技术演变之路


Yandex公司在2011年上市,它的核心产品是搜索引擎。
我们知道,做搜索引擎的公司营收非常依赖流量和在线广告,所以做搜索引擎公司一般会并行推出在线流量分析产品,比如说百度的百度统计,Google的Google Analytics等,Yandex的Yandex.Metricah。ClickHouse就是在这种背景下诞生的。



  • ROLAP:传统关系型数据库OLAP,基于MySQL的MyISAM表引擎

  • MOLAP:借助物化视图的形式实现数据立方体,预处理的结果存在HBase这类高性能的分布式数据库中

  • HOLAP:R和M的结合体H

  • ROLAP:ClickHouse


ClickHouse 的核心特点


超高的查询性能



  • 列式存储:只读取查询所需的列,减少了磁盘 I/O。

  • 向量化计算:批量处理数据,提高了 CPU 使用效率。

  • 数据压缩:高效的压缩算法,降低了存储成本。


水平可扩展性



  • 分布式架构:支持集群部署,轻松处理 PB 级数据。

  • 线性扩展:通过增加节点提升性能,无需停机。


实时数据写入



  • 高吞吐量:每秒可插入数百万行数据。

  • 低延迟:数据写入后立即可查询,满足实时分析需求。


丰富的功能支持



  • 多样的数据类型:支持从基本类型到复杂类型的数据。

  • 高级 SQL 特性:窗口函数、子查询、JOIN 等。

  • 物化视图:预计算和存储查询结果,进一步提升查询性能。


典型应用场景



  • 用户行为分析:电商、游戏、社交平台的实时用户行为跟踪。

  • 日志和监控数据存储:处理服务器日志、应用程序日志和性能监控数据。

  • 商业智能(BI):支持复杂的报表和数据分析需求。


对比ClickHouse MySQL Hadoop Hive


部署与运维



  • 单机部署:适合测试和小规模应用。

  • 集群部署:用于生产环境,可通过 Zookeeper 进行协调。

  • 运维工具:提供了监控和管理工具,如 clickhouse-client、clickhouse-copier。


最佳实践



  • 数据分区:根据时间或其他字段进行分区,提高查询效率。

  • 索引优化:使用主键和采样键,加速数据定位。

  • 硬件配置:充分利用多核 CPU、高速磁盘和大内存。


ClickHouse支持特性


ClickHouse具体有哪些特性呢:



  • 真正的面向列的DBMS

  • 数据高效压缩

  • 磁盘存储的数据

  • 多核并行处理

  • 在多个分布式服务器上分布式处理

  • SQL语法支持

  • 向量化引擎

  • 实时数据更新

  • 索引

  • 适合在线查询

  • 支持近似预估计算

  • 支持嵌套的数据结构

  • 支持数组作为数据类型

  • 支持限制查询复杂性以及配额

  • 复制数据和对数据完整性的支持


ClickHouse和其他对比


商业OLAP


例如:



  • HP Vertica

  • Actian the Vector


区别:



  • ClickHouse 是开源而且免费的


云解决方案


例如:



  • 亚马逊 RedShift

  • 谷歌 BigQuery


区别:



  • ClickHouse 可以使用自己机器部署,无需云付费


Hadoop生态


例如:



  • Cloudera Impala

  • Spark SQL

  • Facebook Presto

  • Apache Drill


区别:



  • ClickHouse 支持实时的高并发系统

  • ClickHouse不依赖于Hadoop生态软件和基础

  • ClickHouse支持分布式机房的部署


开源OLAP数据库


例如:



  • InfiniDB

  • MonetDB

  • LucidDB


区别:



  • 应用规模小

  • 没有在大型互联网服务中蚕尝试


非关系型数据库


例如:



  • Druid

  • Apache Kylin


区别:



  • ClickHouse 可以支持从原始数据直接查询,支持类SQL语言,提供了传统关系型数据的便利。


真正的面向列DBMS


如果你想要查询速度变快:



  • 减少数据扫描范围

  • 减少数据传输时的大小
    在一个真正的面向列的DBMS中,没有任何无用的信息在值中存储。
    例如:必须支持定长数值,以避免在数值旁边存储长度数字,10亿个Int8的值应该大约消耗1GB的未压缩磁盘空间,否则这将强烈影响CPU的使用。由于解压的速度(CPU的使用率)主要取决于未压缩的数据量,即使在未压缩的情况下,紧凑的存储数据也是非常重要的。


因为有些系统可以单独存储独列的值,但由于其他场景的优化,无法有效处理分析查询,例如HBase、BigTable、Cassandra和HypeTable。在这些系统中,每秒可以获得大约十万行的吞吐量,但是每秒不会到达数亿行。


另外,ClickHouse是一个DBMS,而不是一个单一的数据库,ClickHouse允许运行时创建表和数据库,加载数据和运行查询,而不用重新配置或启动系统。


ClickHouse的排列架构


ClickHouse的排列架构
之所以称作 DBMS,因为ClickHouse:



  • DDL

  • DML

  • 权限管理

  • 数据备份

  • 分布式存储

  • 等等功能


数据压缩


一些面向列的DBMS(InfiniDB CE 和 MonetDB)不使用数据压缩,但是数据压缩可以提高性能。


磁盘存储


许多面向列的DBMS(SAP HANA和GooglePower Drill)只能在内存中工作,但即使在数千台服务器上,内存也太小,无法在Yandex.Metrica中存储所有浏览和会话。


多核并行


多核并行进行大型的查询。


在多个服务器上分布式处理


上面列出的DBMS几乎不支持分布式处理,在ClickHouse中,数据可以驻留不同的分片上,每个分片可以是用于容错的一组副本,查询在所有分片上并行处理,这对用户来说是透明的。


SQL支持



  • 支持的查询包括 GR0UP BY、ORDER BY

  • 子查询在FROM、IN、JOIN子句中被支持

  • 标量子查询支持

  • 关联子查询不支持

  • 真是因为ClickHouse提供了标准协议的SQL查询接口,使得现有可视化分析系统能够轻松的与它集成对接


向量化引擎


数据不仅案列存储,而且由矢量-列的部分进行处理,这使我们能够实现高CPU性能。
向量化执行时寄存器硬件层面上的特性,可以理解为消除程序中循环的优化。
为了实现向量化执行,需要利用CPU的SIMD指令(Single Instrution Multiple Data),即用单条指令处理多条数据。现代计算机系统概念中,它是利用数据并行度来提高性能的一种实现方式,它的原理是在CPU寄存器层面实现数据并行的实现原理。


实时数据更新


ClickHouse支持主键表,为了快速执行对主键范围的查询,数据使用合并树(MergeTree)进行递增排序,由于这个原因,数据可以不断的添加到表中,添加数据时无锁处理。


索引


例如,带有主键可以在特定的时间范围内为特定的客户端(Metrica计数器)抽取数据,并且延迟事件小于几十毫秒。


支持在线查询


我们可以使用该系统作为Web界面的后端,低延迟意味着可以无延迟的实时的处理查询。


支持近似计算



  • 系统包含用于近似计算各种值,中位数和分位数的集合函数

  • 支持基于部分(样本)数据运行查询并获得近似结果,在这种情况下,从磁盘检索比例较少的数据。

  • 支持为有限数量的随机秘钥(而不是所有秘钥)运行聚合,在数据中秘钥分发的特定场景下,这提供了相对准确的结果,同时使用较少的资源。


数据复制和对数据完整性支持


使用异步多主复制,写入任何可用的副本后,数据将分发到所有剩余的副本,系统在不同的副本上保持相同的数据。
要注意的是,ClickHouse并不完美:



  • 不支持事务

  • 虽然已支持条件 Delete/Update(mutations),只是非事务型、异步、重写分片数据开销大;生产要谨慎,用 TTL/分区替代更常见。

  • 支持有限的操作系统


最后总结


在大数据分析领域中,传统的大数据分析需要不同框架和技术组合才能达到最终效果,在人力成本、技术能力、硬件成本、维护成本上,让大数据分析变成了很昂贵的事情,很多中小企业非常痛苦,不得不被迫租赁第三方大型数据分析服务。
ClickHouse开源的出现让许多想做大数据且想做大数据分析的很多公司和企业都耳目一新。ClickHouse正是以不依赖Hadoop生态、安装维护简单、查询快速、支持SQL等特点,在大数据领域越走越远。


作者:武子康
来源:juejin.cn/post/7563935896706957363
收起阅读 »

画三角形报错bad_Alloc 原因,回调用错

surfaceCreated(SurfaceHolder holder)和:onSurfaceCreated(GL10 gl, EGLConfig c)是Android OpenGL ES开发中涉及Surface管理的两个关键方法,但它们属于不同类别的回调函数...
继续阅读 »

surfaceCreated(SurfaceHolder holder):onSurfaceCreated(GL10 gl, EGLConfig c)是Android OpenGL ES开发中涉及Surface管理的两个关键方法,但它们属于不同类别的回调函数:


surfaceCreated(SurfaceHolder holder)



  • 所属类‌:SurfaceHolder的回调方法,用于监听Surface创建事件。当SurfaceView的Surface被创建时触发,通常用于初始化渲染线程或资源。 ‌

  • 典型用法‌:在SurfaceHolder.addCallback(this)中注册回调,确保在Surface可用后进行绘制操作。


onSurfaceCreated(GL10 gl, EGLConfig c)



  • 所属类‌:EGL的初始化回调,用于EGL配置完成后的初始化操作。通常在EGL初始化流程中调用,与OpenGL ES渲染线程相关。 ‌


关键区别



  1. 触发时机‌:surfaceCreated在Surface生命周期开始时触发;onSurfaceCreated在EGL配置完成后调用。

  2. 应用场景‌:前者用于自定义视图渲染或相机预览;后者涉及OpenGL ES的底层配置和渲染线程初始化。 ‌

  3. 线程安全‌:surfaceCreated需在非UI线程操作;onSurfaceCreated需在EGL初始化线程中调用。 ‌


作者:小王lj
来源:juejin.cn/post/7559588025615564842
收起阅读 »

前端仔如何在公司搭建 AI Review 系统

web
一、前言在上一篇 《AI 应用开发入门:前端也可以学习 AI》中,我给大家分享了前端学习 AI 应用开发的入门相关知识。我相信很多同学,看完应该都有了一定的收获。未来我会把关于前端学习 AI 的文章都放在这个 《前端学习 AI 之路》&nb...
继续阅读 »

一、前言

在上一篇 《AI 应用开发入门:前端也可以学习 AI》中,我给大家分享了前端学习 AI 应用开发的入门相关知识。我相信很多同学,看完应该都有了一定的收获。未来我会把关于前端学习 AI 的文章都放在这个 《前端学习 AI 之路》 专栏进行更新~

本篇会更偏向实际的应用,我将会运用之前分享的技术和概念,给大家分享如何通过 nodejs + LLM 搭建一个简易的 AI Review 系统的。

在本篇你将收获到:

  1. 设计 AI 应用的思路
  2. 设计提示词的思路
  3. 如何用 NodeJS 结合 LLM 分析代码

二、背景

我相信大家在团队中,都会有 Code Review 这个流程。但是有时候随着人手不够、项目周期紧张,就会出现 review 流程被忽视、或者 review 质量不高的问题。于是,我就在想,是否可以把这种费时、费精力且需要专注的事情,交给一个专门的“AI 员工”去完成呢?答案是可以的。

三、整体效果

目前在我们团队,已经全面的在使用 AI 进行 Review 了,涵盖了前端、后端大大小小 20 + 的项目。得益于在集团内可以使用像(“GPT-4.1、 Calude”)这样更大上下文、更强推理能力的模型,所以整体效果是非常不错的。有时候一些很细微的安全隐患、性能、业务逻辑等问题,AI 都能比人更容易发现。

下面是我用演示的项目呈现的效果,也就是我们即将动手搭建的这个项目。

3.1 评论模式

通过 AI 分析提交的代码,然后会在有问题的代码下,评论出问题类型以及问题的具体原因。

3.2 报告模式

还一种是报告的展示形式。它会在提交的 MR 下输出一个评审报告,列出所有问题的标题、所在位置、以及具体原因。但是,这两种模式实现的本质都一样,只不过是展示结果的方式有不同,这个看你个人喜欢。

四、思路分析

那这个 AI Code Review 应用要怎么实现呢?下面给大家分享一下具体的思路。

4.1 人为流程

首先要做的,就是分析你现有团队人工 review 代码的规范,然后总结出一个具体流程。为什么要这样做?因为让 AI 帮你做事的本质,就是让它模仿你做事。如果连你自己都不清楚具体的执行流程,就更别期待 AI 能把这个事情做好了。

下面是我举例的一个 review 流程,看完后你可以思考一下,自己平时是怎么 review 代码的,有没有一个固定的流程或者方案。如果有,则按照下面的这个“行为 + 目的”的格式记录下来。

  1. 行为:收到的 MR 的提示了;目的:知道有需要 review 的 MR 提交

  1. 行为:查看 commit message;目的:确认本次提交的主题是什么。

  1. 行为:查看改动哪些文件;目的:确认改动范围,主要判断改了哪些业务模块、是否改了公共、或者高风险文件等

  1. 行为:查看文件路径;目的:确认该文件关联的业务、所属的模块等信息,当做后续 diff 评审的前置判断信息。

  1. 行为:查看 diff 内容;目的:判断改动代码的逻辑、安全、性能是否存在问题。结合相关的业务和需求信息,判断是否有实现不合理的地方。
  2. 行为:在有问题的相关代码下,发出评论;目的:在有问题的代码下面,给出修改建议,让开发的同事能够注意和修改一下当前的问题。

4.2 程序流程

上面列举的是一个完整的人为评审代码的流程。但是,如果想让 AI 完全模仿,其实是存在一定的复杂性的。比如,人在评审某处 diff 时,会思考关联的业务、模块等前置信息,然后再做出评论。而不单单只是评审代码表面的编码问题。如果想要 AI 也这样做,还需要引入 RAG 等相关的技术,目的则是为了补充给更多的上下文信息。

为了不增加大家的实现和理解难度,本篇我们实现的是一个简化版本的 AI Code Review。下面是我梳理的 review 流程和与之对应的 AI 应用流程。

画板

4.2 核心问题

这次搭建的 AI Code Review 应用,本质上是一个 NodeJS 服务。这个服务通过感知 MR 事件,获取 diff 交给 LLM 分析,得到结论以后,会输出评论到 GitLab。整体流程图如下:

所以,我们要面对这些核心问题是

  1. node 服务如何感知 GitLab 的 MR 提交
  2. 如何获取 MR 中每个文件改动的 diff
  3. 如何让编写提示词,让大模型评审和分析并输出结构化的数据
  4. 如何解析数据以及异常的处理
  5. 如何发送评论到 gitlab
  6. 如何推送状态到企微

接下来,我们带着上面的问题,来一步步实现这个 AI Code Review 应用。

五、具体实现

5.1 创建项目

创建一个 NestJS 的项目(用什么技术框架都可以,你可以使用你最熟悉的 Node 开发框架。重点是关注实现的核心步骤和思路,这个演示的项目我开源了,可以在 GitHub 上查看完整的代码)

nvm use 20

使用 nest 命令初始化一个项目

nest new mr-agent

5.2 实现 webhook 接口

首先我们来解决 node 服务如何感知 MR 事件的问题

Webhook

像 GitLab、GitHub 都会允许用户在项目中配置 webhook。它是干嘛的呢? webhook 可以让外部的服务,感知到 git 操作的相关事件(如 push 、merge 等事件)。比如我在合并代码时,gitlab 就会把 MR 事件,通过这个 hook 发送到我们搭建的服务上。

画板

以 GitLab 为例,它会允许开发者在每个项目中配置多个 webhook 接口。比如,咱们配置一个 http://example.com/webhook/trigger 的地址。当发生相关 git 事件时,GitLab 就会往这个地址推送消息。

代码实现

所以,我们要做的第一件事,就是定义一个接口 url,用于接收 GitLab 的 webhook 事件。下面的代码中,实现了一个处理/webhook/trigger路由的 controller,它的主要职责是接收 MR 事件并且解析 body 和 header 中的参数,代码如下(完整代码

Body

body 中会包含很多有用的的信息,如 Git 仓库信息、分支信息、MR 信息、提交者信息等,这些数据是 GitLab 调用 webhook 接口时发送过来的,在后续的逻辑中,都会用到里面的数据。

  • object_type/object_kind:描述的事件的类型,例如 merge 事件、push 事件等。
  • project:主要是描述仓库相关的信息,例如项目 id、名称等
  • object_attributes: 主要包含本次 MR 相关的信息,如目标分支、源分支、mr 的 id 等等
  • user:提交者的信息

Header

header 中是我们自己目定义的配置信息,核心有三个

  • x-ai-mode:评论的模式(report 报告模式、 comment 评论模式)
  • x-push-url:用于推送状态的地址(推送到企微、或者飞书的机器人)
  • x-gitlab-token:gitlab 中的 access token,用于后续 GitLab API 调用鉴权

调试问题

调试开发的这个接口确实是一个比较麻烦的问题。因为 GitLab 基本都是内网部署,想要真实调试接口,一是需要真实代码仓库,二是需要想办法把 GitLab 的请求转发到本地来。这里我给大家分享三个办法:

内网转发

使用内网转发的办法,第三方的例如 ngroklocaltunnelfrp 等。如果你们公司的部署平台本身支持流量转发到本地,那就更好了(我用的是这个办法)。

ApiFox、Postman

先将服务部署到你们公司 GitLab 可以访问的服务器上,手动触发 MR 事件

image.png

然后在日志上打印完整的 header 和 body,然后复制到 ApiFox、Postman 上在本地模拟请求

image.png

问 AI

😁 最后一个办法就是,根据你的场景,问问 AI 怎么做 image.png

5.3 获取 diff 内容

在能够接受到 GitLab 发送的 MR 事件后,就要解决如何获取 diff 的问题。这一步很简单, 调用 GitLab 官方的 API 就可以。重点就是两个核心逻辑:

  1. 获取全部文件的 diff 内容
  2. 过滤非代码文件

获取 diff 内容

gitlab 的 api 路径一般是一样的。唯一的区别就是不同公司的部署域名不同。baseUrl 需要配置成你公司的域名,projectId 和 mrId 都可以在 body 中取到(完整代码

调用成功以后,获取的数据如下,changes 中会包含每个文件的 diff

过滤文件

因为并不是所有的文件都需要让 LLM 进行 review ,例如像 package.json、package-lock.json 等等。所以需要把这部分非代码文件过滤出来。

5.4 设计提示词

有了每个文件的 diff 数据以后,就是解决如何分析 diff 内容并输出有效结论的问题。其实这个问题的本质,就是如何设计系统提示词。

提示词思路

首先我们先思考一下编写提示词的目的是什么?我们期望的是,通过提示词指引 LLM,当输入 diff 文本的时候,它能够分析里面的代码并输出结构化的数据。

画板

我们希望 LLM 返回的是一个数组,数组的每一项是对每一个问题的描述,里面包含标题、文件路径、行号、具体的内容等,数据结构如下:

interface Review {
// 表示修改后的文件路径
newPath: string;
// 表示修改前的文件路径
oldPath: string;
// 表示评审的是旧代码还是新代码,如果评审的是 + 部分的代码,那么 type 就是 new,如果评审的是 - 部分的代码,那么 type 就是 old。
type: 'old' | 'new';
// 如果是 old 类型,那么 startLine 表示的是旧代码的第 startLine 行,否则表示的是新代码的第 startLine 行
startLine: number;
// 如果是 new 类型,那么 endLine 表示的是旧代码的第 endLine 行,否则表示的是新代码的第 endLine 行
endLine: number;
// 对于存在问题总结的标题,例如(逻辑错误、语法错误、安全风险等),尽可能不超过 6 个字
issueHeader: string;
// 清晰的描述代码中存在、需要注意或者修改的问题,并给出明确建议
issueContent: string;
}

interface MRReview {
reviews: Review[];
}

之所以需要这种结构化的数据,是因为后续在调用 GitLab API 发送评论的时候,需要用到这些参数。

整体思路确定好了,接下来我们就来编写具体的系统提示词。

角色设定

角色设定就是告诉 LLM 扮演什么角色以及它的具体要做什么事情

你是一个代码 MR Review 专家,你的任务是评审 Git Merge Request 中提交的代码,如果存在有问题的代码,你要提供有价值、有建设性值的建议。
注意,你评审时,应重点关注 diff 中括号后面带 + 或 - 的代码。

输入内容

上面有说到,我们不仅需要 LLM 分析代码的问题,还需要它把问题代码所在的文件路径、行号分析出来。

但是,如果你直接把原生的 diff 内容输入给它,它是不知道这些信息。因为原生的 diff 并没有具体的行号、新旧文件路径信息的。

@@ -1,16 +1,13 @@
import { Injectable } from '@nestjs/common';

-interface InputProps {
- code_diff: string;
- code_context: string;
- rules?: string;
-}
+type InputProps = Record;

interface CallDifyParams {

所以我们需要扩展输入的 diff,给它增加新旧文件的路径、以及每一行具体的行号,例如 (1, 1) 表示的是当前行,是旧文件中的第 1 行,新文件中的第 1 行。这个后面会说如何扩展,这里我们只是要先设计好,并告诉 LLM 我们会输入什么格式的内容

## new_path: src/agent/agent.service.ts
## old_path: src/agent/agent.service.ts
@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {

有了这些完善的信息,LLM 才知道有问题的代码在哪个文件以及它所在的具体行号

加解释

diff 经过我们的扩展以后,就不再是标准的描述 diff 的 Unified Format 格式了,所以必须向 LLM 解释一下格式的含义,增强它对输入的理解,避免它随便臆想。

我们将使用下面的格式来呈现 MR 代码的 diff 内容:

## new_path: src/agent/agent.service.ts
## old_
path: src/agent/agent.service.ts

@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_
context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {

- 以 ”## new_path“ 开头的行内容,表示修改后的文件路径
- 以 ”## old_
path“ 开头的行内容,表示修改前的文件路径
- @@ -1,16 +1,13 @@ 是统一差异格式(Unified Diff Format)中的hunk header,用于描述文件内容的具体修改位置和范围
- 每一行左侧括号内的两个数字,左边表示旧代码的行号,右边表示新代码的行号
- 括号后的 + 表示的是新增行
- 括号后的 - 表示的是删除行
- 引用代码中的变量、名称或文件路径时,请使用反引号(`)而不是单引号(')。

加限制

加限制的主要目的是指引 LLM 按照固定的数据类型进行输出。这里我们会告诉 LLM 具体的 TS 类型,避免它输出一些乱七八糟的类型,导致后续在代码中解析和使用的时候报异常。例如,数字变成字符串、字符串变成数组等。

你必须根据下面的 TS 类型定义,输出等效于MRReview类型的YML对象:

```ts
interface Review {
// 表示修改后的文件路径
newPath: string;
// 表示修改前的文件路径
oldPath: string;
// 表示评审的是旧代码还是新代码,如果评审的是 + 部分的代码,那么 type 就是 new,如果评审的是 - 部分的代码,那么 type 就是 old。
type: 'old' | 'new';
// 如果是 old 类型,那么 startLine 表示的是旧代码的第 startLine 行,否则表示的是新代码的第 startLine 行
startLine: number;
// 如果是 new 类型,那么 endLine 表示的是旧代码的第 endLine 行,否则表示的是新代码的第 endLine 行
endLine: number;
// 对于存在问题总结的标题,例如(逻辑错误、语法错误、安全风险等),尽可能不超过 6 个字
issueHeader: string;
// 清晰的描述代码中存在、需要注意或者修改的问题,并给出明确建议
issueContent: string;
}

interface MRReview {
reviews: Review[];
}
```

在限制的类型中,最好是增加一些注解,让 LLM 能够理解每个字段的含义。

加示例

加示例的主要目的是告诉 LLM 按照固定的文件格式进行输出,这样我们就可以直接拿 LLM 的输出,进行标准化的解析,转换成实例的数据进行使用,伪代码如下:

// 调用 LLM 的接口
const result = await callLLM('xxxxx');

// 解析数据
const data = yaml.load(result);

// 操作数据
data.reviews.forEach(() => { })

提示词描述如下

输出模板(注意,我只需要 yaml 格式的内容。yaml 内容的前后不要有其他内容):

```yaml
reviews:
- newPath: |
src/agent/agent.service.ts
oldPath: |
src/agent/agent.service.ts
startLine: 1
endLine: 1
type: |
old
issueHeader: |
逻辑错误
issueContent: |
...
- newPath: |
src/webhook/decorators/advanced-header.decorator.ts
oldPath: |
src/webhook/decorators/commmon-header.decorator.ts
startLine: 1
endLine: 1
type: |
new
issueHeader: |
性能风险
issueContent: |
...
```

这里简单说一下,为什么选择 yaml 而不是 json。因为在实践的过程中,我们发现 json 解析异常的概率会比 yaml 高很多,因为 json 的 key 和 value 是需要双引号("")包裹的,如果 issueContent 中包含了代码相关的内容且存在一些双引号、单引号之类的符号,就很容易导致报错,而且比较难通过一些替换规则进行兜底处理。

最后完整的提示词这里:提示词

调试

这里再告诉大家一个提示词的调试技巧,你可以先在 Coze、Dify 这样的平台上,通过工作台不断调试你的提示词,直到它能够稳定的输出你满意的结果。

image.png

5.5 扩展、组装 diff

上面我们有说到,通过 GitLab 获取的原始 diff 是没有新旧文件路径和具体的新旧行号的,这个需要通过代码计算来补全这些信息。这一小节,我们就来解决 diff 的扩展、组装问题。

扩展

扩展主要做两个事:

  • 在 diff 头部加新旧文件的路径
  • 在每一行加新旧文件中的行号

加路径比较简单,可以在获取每个文件的 diff 数据的时候,拿到新旧文件的路径的,取值后加上即可。

image.png 加行号稍微麻烦一点,我们需要将当前文件的 diff 按照 hunk 拆分成不同的块,然后会根据 hunk head 计算每行在新旧文件中的真实行号。

image.png

为了防止有些同学不清楚 diff 格式的结构,我这里简单标注一下。 在下面这个 diff 中,像 “@@ -1,16 +1,13 @@” 这样的内容就是 Hunk Head,用于描述后续 diff 内容在新旧文件中的起始行号。用框住的第一个 hunk 为例:

  • -1,16: 表示 import { Injectable } from '@nestjs/common'; 是在旧文件中的第 1 行,改动范围是往后的一共 16 行,需要忽略 “+” 加号开头的行。
  • +1,13:表示是import { Injectable } from '@nestjs/common';在新文件中的第 1 行,改动范围是往后的一共 13 行,需要忽略 “-” 加号开头的行。

然后图中被我用红框标注的连续代码片段就是 hunk,它一般由 hunk header + 连续的代码组成。一个文件的 diff 可能会有多个 hunk。

  • hunk 中 “+” 开头的行,表示新文件中增加的行
  • “-” 开头的行,表示旧文件中被删除的行 image.png

这里需要先遍历每个文件的 diff,然后按 hunk head 来分割内容块。

const hunks = splitHunk(diffFile.diff);

代码如下:

逻辑是将 diff 按 “\n” 分割成包含所有行的数组,然后遍历每一行。每当遍历到一个 hunk head 就创建一个新的 hunk 结构,然后通过正则提取里面的起始行号,并将后续遍历到的行都保存起来,直到它遇到一个新的 hunk head。

接着就是遍历 hunk,计算每个 hunk 中每一行的具体行号。

comptuedHunkLineNumer 的代码如下:

核心逻辑是:

  1. 使用 oldLineNumber、newLineNumber 两个独立计数器,记录新旧文件的当前行号
  2. 遍历到 “-” 开头的行,oldLineNumber + 1,记录行号(oldLineNumber + 1, )
  3. 遍历到 “+” 开头的行,newLineNumber + 1,记录行号( , newLineNumber + 1)
  4. 遍历常规的行,oldLineNumber 和 newLineNumber 都 + 1,记录行号(oldLineNumber + 1, newLineNumber + 1)

为了让你更清晰理解这个逻辑,我在 diff 中标注一下。下面是计算旧文件中的行号,我们只会对“-”开头的行和普通的行进行计数,忽律 “+” 开头的行。

计算新文件中的行,此时我将不计算 “-” 开头的行。所以type InputProps = Record;这行代码,在合并后的新文件中,真正的行号是在第 15 行。

处理后 diff 的每一行,都会带上新旧文件中的行号

@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;
(9, 9)
(10, 10) interface CallDifyParams {
(11, 11) input: InputProps;
(12, 12) query: string;
(13, 13) conversation_id?: string;
(14, 14) user: string;
( , 15) + apiKey: string;
(16, 16) }
(17, 17)

组装

得到每个文件扩展的 diff 以后,便是将 commit message 和所有文件 diff 拼接到一个字符串中,后续会把这个拼接好的字符串直接输入给 LLM 进行分析。

commit message: feat: 调整 review 触发逻辑,增加请求拦截器

##new_path: src/agent/agent.service.ts
##old_path: src/agent/agent.service.ts
@@ -1,16 +1,13 @@
(1, 1) import { Injectable } from '@nestjs/common';
(2, 2)
(3, ) -interface InputProps {
(4, ) - code_diff: string;
(5, ) - code_context: string;
(6, ) - rules?: string;
(7, ) -}
( , 8) +type InputProps = Record;

## new_path: src/webhook/decorators/advanced-header.decorator.ts
## old_path: src/webhook/decorators/advanced-header.decorator.ts
@@ -0,0 +1,152 @@
( , 1) +import {
( , 2) + createParamDecorator,
( , 3) + ExecutionContext,
( , 4) + BadRequestException,
( , 5) +} from '@nestjs/common';
( , 6) +
( , 7) +/**
( , 8) + * 高级 Header 装饰器,支持类型转换和验证
( , 9) + */
( , 10) +export const AdvancedHeader = createParamDecorator(

5.6 对接 LLM

现在我们已经有了系统提示词、处理好的 diff 内容,接着就是如何调用 LLM 分析结果。

画板

申请 DeepSeek

演示的案例中,我用的是 DeepSeek-v3 的模型。如果能够使用 GPT-4.1 或者 Calude 模型的同学,你可以优先选择使用这两个模型。

这里你需要去到 DeepSeek 官网申请一个 API Key

然后去充值个几块钱,你就可以使用 DeepSeek 这个模型了。

具体申请和使用步骤,官网文档都讲得很清楚了,这里不过多赘述。

调用 LLM

申请完 DeepSeek 的 API Key 以后,就可以通过接口调用了

这里主要关注一下调用接口的入参:

  • model: 如果是 deepseek 的话,你选择 deepseek-chat还是 deepseek-reasoner都可以
  • messages: 这里我们输入两个 message,一个是系统提示词,一个是扩展的 diff
  • temperature:设置成 0.2,提高输出的精确性

如果一切调用成功的话,你应该会得到 LLM 一个这样的回复:

```yaml
reviews:
- newPath: |
src/agent/agent.service.ts
oldPath: |
src/agent/agent.service.ts
startLine: 8
endLine: 8
type: |
new
issueHeader: |
类型定义不严谨
issueContent: |
将 `InputProps` 从具体的接口类型改为 `Record
`,虽然提升了灵活性,但丢失了原有的类型约束,容易导致后续代码中出现属性拼写错误或类型不一致的问题。建议保留原有字段定义,并在需要扩展时通过继承或联合类型实现更好的类型安全。
- newPath: |
src/webhook/webhook.controller.ts
oldPath: |
src/webhook/webhook.controller.ts
startLine: 38
endLine: 40
type: |
new
issueHeader: |
参数注入冗余与未使用参数
issueContent: |
在 `trigger` 方法中注入了 `@GitlabToken()`、`@QwxRobotUrl()` 等参数,但实际方法体内并未使用这些参数,而是继续从 headers 中解析相关信息(已被删除)。建议移除未用到的装饰器参数,或者直接替换原有 header 获取逻辑,避免混乱和冗余。
```


5.7 数据解析和异常处理

有了 LLM 回复的数据以后,接着要做的就是将字符串解析成数据,以及处理解析过程中的异常问题

数据解析

这里主要做两个是事,一个是提取 yaml 的内容

提取完字符串以后,然后通过 js-yaml这个包解析数据

const mrReview = yaml.load(yamlContent) as MRReview;

至此,你已经得到一份经过 LLM 分析后产生的实例化的数据了

异常处理

但是你以为到这里就结束了吗?实际的情况却是 LLM 会因为它的黑盒性和不确定性,偶然的输出一些奇奇怪怪的字符或格式,导致出现解析的异常。

场景1:多余的 '\n' 符号

有时候 LLM 在输出的时候,会给 type 字段多加一个 '\n' 符号

{
newPath: "src/agent/agent.service.ts",
oldPath: "src/agent/agent.service.ts",
startLine: 10,
endLine: 12,
type: "new\n"
....
}

看日志的时候,感觉一直没问题。可是到一些具体场景判断的时候,就会开始怀疑人生。当时一些关于 type 的判断,我想破脑袋也没想明白为什么 new 会走到 old 的逻辑里面,结果仔细一看,还有一个换行符……

所以针对这个场景,需要单独加一些处理逻辑。通过 replace 把字符串中的换行符全部去掉。

场景2:多余的空格符号

我们知道 yaml 的字段结构是按空格来控制的,但有时候 LLM 偏偏就在某些字段前面少一个或者多个空格,排查的时候也是非常的头痛,例如下面的 issueHeader、issueContent 因为少了空格,而导致 yaml 解析异常…

我的办法就是让 AI 写了一个兜底处理方法。在解析异常的时候,通过兜底方法再解析一次。 具体代码(查看里面的 fixYamlFormatIssues 方法)

更多场景

因为 LLM 偶现的不稳定性,会导致出现各种奇奇怪怪的问题。目前的解决思路有三个:

  1. 使用更强大的模型,并调低 temperature 参数
  2. 调试出更完善的提示词,通过加限制、加示例等技巧,提高提示词的准确性
  3. 特殊场景,特殊手段。例如通过编码等手段,提前防范这些异常

5.8 上下文分割

还有一个需要解决的问题就 LLM 的上下文长度的限制。像 GPT-4.1 上下文长度有 100w 个 token,但是你用 deepseek 的话,可能只有 64000 个。

一旦你输入的提示词 + diff 内容超过这个上下文,就会报错导致 LLM 无法正常解析。这时我们就不得不把输入的 diff 拆分成多份,然后并行调用 LLM,最后整合数据。

画板

解决这个问题的思路也很简单,每次调用 LLM 前,计算一下系统提示词 + Diff 内容需要消耗的 token,如果超了就把 diff 多差几份。

import { encoding_for_model, TiktokenModel } from '@dqbd/tiktoken';

const encoding = encoding_for_model(this.modelName);
const tokens = encoding.encode(text);
const count = tokens.length;
encoding.free();

我用的是 @dqbd/tiktoken 这个包计算 token,它里面包含了大多数模型的 token 计算方式。

5.9 发送结果

在有了处理好的 review 数据以后,我们就可以调用 GitLab 的接口发送评论了

从上面方法的入参可以看到,newPath、oldPath、endLine、issuceContent 等数据,都是在通过 LLM 分析以后得出来的。

5.10 小结

至此,这个 AI Code Review 的关键流程,我已经讲完了。下面再来总结一下两个流程:

  • 逻辑流程
  • 使用流程

逻辑流程

  • 部署 NodeJS 服务
  • 开发 webhook 接口,接受 MR 事件
  • 收到事件后,获取 Diff 内容
  • 有了 Diff 内容后,扩展行号、文件路径,拼成一个字符串
  • 进行 token 分析,超了就分多份进行分析
  • 调用 LLM,输入系统提示词、Diff
  • 拿到 YAML 结构的分析数据
  • 解析数据、处理异常
  • 发送评论到 GitLab

使用流程

  • 申请 access token
  • 配置 webhook
  • 发起 MR
  • 收到 AI 分析的评论

六、最后

6.1 期待

本篇给大家分享了一个 AI Code Review 应用开发的简单案例。我希望大家可以看完以后,可以在自己的业务或者个人项目中去实践落地,然后再回到评论区给与反馈,展示你的成果。

6.2 学习方法

如果看到文章中有任何不懂的,我建议你都可以直接问 AI。我看掘金自带的这个 AI 助手也挺方便的。我们既然要学习 AI,就要多用 AI 的方式去学习。当然,你也可以直接留言问我。

image.png

6.3 关注

最后呢,也是希望大家关注我,我会持续在这个专栏更新我的文章。本想着坚持能够一个月输出两篇,但是在工作忙碌 + 文章质量的不断权衡中,还是写了很久,才写出这一篇。原创不易,需转载请私信我~

这个演示的项目地址:github.com/zixingtangm… (可以的话,也帮忙点点 star ⭐️ 哈哈)


作者:唐某人丶
来源:juejin.cn/post/7532596434031149106

收起阅读 »

使用 AI 助手提升前端代码质量:自动代码审查实战

web
最近在带团队的时候,发现代码审查(Code Review)总是成为项目进度的一个瓶颈。一方面,高级工程师的时间很宝贵,不可能审查每一行代码;另一方面,初级工程师又急需及时的反馈来提升。于是我就在想:能不能用 AI 来解决这个问题? 经过一番研究和实践,我搭建了...
继续阅读 »

最近在带团队的时候,发现代码审查(Code Review)总是成为项目进度的一个瓶颈。一方面,高级工程师的时间很宝贵,不可能审查每一行代码;另一方面,初级工程师又急需及时的反馈来提升。于是我就在想:能不能用 AI 来解决这个问题?


经过一番研究和实践,我搭建了一个 AI 代码审查助手,效果出乎意料的好!今天就来分享下这个小工具是怎么做的。


为什么需要 AI 代码审查?


说实话,最开始团队里有不少质疑的声音:"AI 能审查什么代码?""能发现真正的问题吗?"但是经过一段时间的使用,大家发现 AI 代码审查确实能解决很多痛点:



  1. 人工审查的问题



    • 😫 审查疲劳:谁能一直盯着代码看?

    • ⏰ 反馈延迟:等高级工程师有空可能需要好几天

    • 🤔 标准不一:每个人的审查重点和标准都不太一样



  2. AI 审查的优势



    • 🤖 24/7 全天候服务,随时可用

    • 🎯 审查标准统一且可配置

    • ⚡️ 秒级反馈,再也不用等人了

    • 📚 会不断学习和改进




实战:搭建 AI 代码审查助手



温馨提示:这个项目用到了 OpenAI API,需要自己准备 API Key。新账号有免费额度,够用来测试了。



1. 项目初始化


mkdir ai-code-review
cd ai-code-review
npm init -y
npm install openai eslint prettier

2. 核心代码实现


这是最关键的部分,我们需要:



  1. 处理各种代码格式

  2. 设置合适的提示词(prompt)

  3. 处理 API 限流和错误


// src/codeReviewer.ts
import { Configuration, OpenAIApi } from 'openai'
import { rateLimit } from '@/utils/rate-limit'

class AICodeReviewer {
private openai: OpenAIApi
private reviewCache: Map<string, string>

constructor(apiKey: string) {
const configuration = new Configuration({
apiKey: apiKey
})
this.openai = new OpenAIApi(configuration)
this.reviewCache = new Map() // 缓存常见问题的反馈
}

private async generateReviewPrompt(code: string, language: string): string {
// 根据不同语言定制提示词
const basePrompt = `作为一个资深的${language}开发专家,请审查以下代码,重点关注:
1. 代码质量和最佳实践
2. 潜在的性能问题
3. 安全隐患
4. 可维护性
5. 错误处理

请用中文回复,按严重程度排序,并给出具体的修改建议。

代码:
${code}`


return basePrompt
}

async reviewCode(code: string, language: string = 'JavaScript'): Promise<string> {
try {
// 检查缓存
const cacheKey = this.generateCacheKey(code)
if (this.reviewCache.has(cacheKey)) {
return this.reviewCache.get(cacheKey)!
}

// 限流检查
if (!await rateLimit.check()) {
throw new Error('请求太频繁,请稍后再试')
}

const prompt = await this.generateReviewPrompt(code, language)

const completion = await this.openai.createChatCompletion({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: '你是一个资深的代码审查专家,擅长发现代码中的问题并提供建设性的改进建议。'
},
{ role: 'user', content: prompt }
],
temperature: 0.7, // 让回复更有创意一些
})

const review = completion.data.choices[0].message?.content || ''

// 缓存结果
this.reviewCache.set(cacheKey, review)

return review
} catch (error: any) {
console.error('代码审查失败:', error)
throw new Error(this.formatError(error))
}
}

private generateCacheKey(code: string): string {
// 简单的缓存 key 生成
return code.trim().substring(0, 100)
}

private formatError(error: any): string {
if (error.response?.status === 429) {
return '当前请求较多,请稍后再试'
}
return '代码审查服务暂时不可用,请重试'
}
}

export default AICodeReviewer

3. VSCode 扩展实现


这是我们团队最常用的功能,可以直接在编辑器里获取 AI 反馈:


// extension.ts
import * as vscode from 'vscode'
import AICodeReviewer from './codeReviewer'

export function activate(context: vscode.ExtensionContext) {
// 注册命令
let disposable = vscode.commands.registerCommand('aiCodeReview.review', async () => {
const editor = vscode.window.activeTextEditor
if (!editor) {
vscode.window.showErrorMessage('请先打开要审查的代码文件')
return
}

// 获取当前文件的语言
const language = editor.document.languageId
const code = editor.document.getText()

// 显示加载状态
const statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left
)
statusBarItem.text = '$(sync~spin) AI 正在审查代码...'
statusBarItem.show()

try {
const reviewer = new AICodeReviewer(
vscode.workspace.getConfiguration().get('aiCodeReview.apiKey') as string
)
const review = await reviewer.reviewCode(code, language)

// 在侧边栏显示结果
const panel = vscode.window.createWebviewPanel(
'aiCodeReview',
'AI 代码审查报告',
vscode.ViewColumn.Two,
{}
)

panel.webview.html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { padding: 15px; }
.review { white-space: pre-wrap; }
.severity-high { color: #d73a49; }
.severity-medium { color: #e36209; }
.severity-low { color: #032f62; }
</style>
</head>
<body>
<h2>AI 代码审查报告</h2>
<div class="review">${this.formatReview(review)}</div>
</body>
</html>
`

} catch (error: any) {
vscode.window.showErrorMessage(error.message)
} finally {
statusBarItem.dispose()
}
})

context.subscriptions.push(disposable)
}

实战经验分享


经过几个月的使用,我总结了一些经验:


1. 提示词(Prompt)很重要



  • 🎯 要让 AI 关注特定领域的最佳实践

  • 📝 提供具体的评审标准和格式要求

  • 🌐 针对不同编程语言定制提示词


2. 合理的使用场景



  • ✅ 适合:代码风格检查、基本的逻辑问题、文档完整性

  • ❌ 不适合:业务逻辑正确性、系统架构决策、性能调优


3. 成本控制


在实际使用中,我发现几个控制成本的好办法:



  1. 缓存常见问题



    • 类似的代码问题可以复用审查结果

    • 显著减少 API 调用次数



  2. 合理的调用策略



    • 不是每次保存都触发审查

    • 设置合适的调用间隔

    • 批量处理多个文件的审查



  3. 优化 token 使用



    • 只发送必要的代码片段

    • 限制单次审查的代码长度

    • 选择合适的模型(3.5 通常就够用)




实际效果


使用这个工具后,我们团队有了一些明显的改善:



  1. 代码质量



    • 基础问题大幅减少

    • 代码风格更统一

    • 新人学习曲线变缓



  2. 开发效率



    • PR 审查时间减少 50%

    • 反馈速度提升

    • 开发体验更好



  3. 团队氛围



    • 减少了代码审查的争议

    • 新人更敢提问和讨论

    • 代码审查不再是负担




写在最后


这个 AI 代码审查助手现在已经成为我们团队日常开发的好帮手了。它不是来替代人工代码审查的,而是帮我们过滤掉那些基础问题,让我们能把精力放在更有价值的讨论上。


如果你也想尝试,建议从小范围开始,慢慢调整和优化。毕竟每个团队的情况都不一样,找到最适合自己团队的方式才是关键。



如果觉得有帮助,别忘了点赞关注!之后我还会分享更多提升开发效率的实战经验~



作者:Ethan独立开发
来源:juejin.cn/post/7440818887455604736
收起阅读 »

理想正式官宣开源!杀疯了!

最近,新能源汽车制造商「理想汽车」面向业界搞了一个大动作,相信不少同学也看到了,那就是: 正式宣布开源「理想星环OS」操作系统,并且欢迎各位开发者参与验证开源组件的功能和性能。 作为一名开发者和理想车主,说实话第一眼看到这个信息时还是挺意外的,万万没想到,如今...
继续阅读 »

最近,新能源汽车制造商「理想汽车」面向业界搞了一个大动作,相信不少同学也看到了,那就是:


正式宣布开源「理想星环OS」操作系统,并且欢迎各位开发者参与验证开源组件的功能和性能。


作为一名开发者和理想车主,说实话第一眼看到这个信息时还是挺意外的,万万没想到,如今汽车制造商也开始玩开源这一套了。



「理想星环OS」是理想汽车历时3年所研发的汽车操作系统,在车辆中担任“中央指挥官”这一角色,向下管理车辆硬件,向上支撑应用软件。



具体来说,「理想星环OS」包含如下四个组成部分,用于高效调度全车资源并确保汽车功能稳定运行。



  • 辅助驾驶OS(大脑):用于处理复杂的思维过程,以确保辅助驾驶又快又好地工作。

  • 智能车控OS(小脑):用于控制车辆“肢体”,快速执行各种车辆基础控制命令。

  • 通信中间件(神经系统):负责车内各个模块(如刹车、屏幕、雷达)间的高效可靠通信。

  • 信息安全系统(免疫系统):负责数据加密保护以及身份认证和权限管控等信息安全的保障。


星环OS主要组成部分


早在今年3月份的时候,理想汽车CEO李想就曾在2025中关村论坛年会上宣布过,理想汽车自研整车操作系统 ——“理想星环OS”将会开源,而这一承诺终于在最近开始逐步向外兑现。



按照理想官方发布的开源计划时间轴来看,「理想星环OS」的开源将会分为三个阶段来逐步落地。



  • 第一阶段主要是开源星环OS 0.1.0版本,包含安全实时RTOS以及通信总线Lite。

  • 第二阶段开源星环OS 1.0.0版本,包含完整的智能车控系统以及智能驾驶系统基础能力,时间节点为今年的6月30号左右。

  • 第三阶段开源则将会包括完整的智能驾驶系统以及虚拟化引擎,时间节点定在了2025年的7月后。



并且理想承诺,星环OS将会采用宽松型的Apache License,既不会通过开源来收取费用,也不会干涉代码的使用方式,更不会控制使用者的数据。


按照官方的说法,第一阶段的开源目前已经正式兑现,代码已经托管于国内的Gitee平台之上。


出于好奇,我也特地去Gitee平台上搜了一下。


果然,理想汽车已经在Gitee平台上创建了一个名为「HaloOS」的开源组织,里面包含了一阶段开源相关的多个代码仓库和文档仓库。



具体来看,目前的开源代码主要是 智能车控OS(VCOS)通信总线lite(VBSlite) 两大部分,并且其开源仓库划分得非常细,文档是文档,代码是代码,配置是配置,示例是示例。



文档仓库我们以智能车控OS(VCOS)文档为例,其专门搞了一个文档仓库和详细文档说明,并且附有详细的快速跳转链接,大家可以感受一下这个文档仓库的组织风格,还是非常便于开发者使用的。



docs
├── OVERVIEW.md # 项目概述
├── README.md # 文档结构简介(即本文)
├── _static/image # 文档中用到的图片资源
├── api_reference # API参考文档
├── configuration_reference # 配置项参考文档
├── key_technical # 关键技术说明
├── porting # 芯片移植文档
├── quick_start # 快速入门指南
└── user_manual # 开发者手册与详细说明
├── components # 功能组件使用说明
├── kernel # 内核模块文档
└── studio # Studio集成开发环境相关文档

而代码仓库这一块,我们以通信总线lite(VBSlite)工程核心组件之一的MVBS代码仓库为例,仓库说明里给出了详细的代码架构组织,大家也可以感受一下。


mvbs
├── README.md # 这个是MVBS仓库的readme
├── build.mk # 用于构建的makefile文件
├── CMakeLists.txt # cmake编译脚本
├── posix_aux # 为linux和windows平台提供扩展支持库
├── include
│ ├── mcdr # 序列化接口
│ ├── mvbs # MVBS头文件集合
│ │ ├── adapter # 适配层头文件
│ │ ├── core # MVBS内部核心的实体定义和操作
│ │ ├── diag # 诊断相关的头文件
│ │ ├── rte # RTE接口文件
│ │ ├── rtps # RTPS协议元素定义文件
│ │ ├── sections # 用于支持内存layout
│ │ └── utils # 常用的工具文件
│ └── rpc # RPC头文件
└── src
├── adapter # 适配层实现
│ ├── auto # 基于VCOS 适配层的参考实现
│ └── posix # 基于POSIX提供的适配层实现
├── core
│ ├── diag # 诊断工具的实现
│ ├── discovery # 实体发现协议的实现
│ ├── entities # MVBS内部实体的实现
│ ├── include # 提供给MVBS内部的头文件
│ ├── messages # 报文组装的实现
│ ├── mvbs # MVBS内部接口层的实现
│ ├── netio # 网络接口的封装实现
│ ├── qos # E2E和WLP的实现
│ ├── storages # CacheChange和History的实现
│ ├── transport # Transport的实现
│ └── utils # 常用工具的实现
├── mcdr # 序列化库的实现
├── rpc # RPC的实现
└── rte # RTE接口的实现

再看一下具体代码,函数和代码组织都比较宽松,是我个人比较喜欢的风格,另外关键步骤或关键字段设有代码注释,阅读起来也便于理解。



并且仓库里还给出了非常详细的快速入门开发者手册,内容我看了一下,内容甚至从安装 git-repo 工具开始,确实给得非常详细。



追了其中几个比较核心的代码仓库后我们会发现,这几个核心项目源码都是基于C语言来实现的,这也再次说明了 C 语言在某些关键系统中不可撼动的核心地位



大家感兴趣的话也可以上去学习学习相关的代码,研究通了以后想进新能源智能车企做核心系统研发那不就是分分钟的事情了。



众所周知,这两年新能源智能汽车领域的竞争也进入到白热化阶段了,各家新能源车企都在不断地进行产品优化和技术摸高,这个趋势在未来很长一段时间内想必还会继续保持。


按照理想官方的说法,此次开源的主要目的是促进行业合作,旨在破解行业“重复造轮子”的困局,同时通过生态的共建来实现车企之间、车企与其他厂商之间的互利共赢,最终普惠到每个用户。


当然不管他们怎么去说,作为一名开发者我们都清晰地知道,开源的背后其实也是生态的建设和博弈,说实话这一步,理想在新能源车企阵营里走得还是非常超前的。


最近这两年,我自己一直都挺关注新能源汽车市场的,线下也试驾和体验过诸多品牌的新能源汽车产品,也切实感受到了这几年技术和产品的飞速迭代。希望国产智能新能源汽车能持续崛起,为用户带来更多技术普惠和感动人心的好产品。



注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。



作者:CodeSheep
来源:juejin.cn/post/7503810377554984998
收起阅读 »

Maven高级

一. 分模块设计与开发 分模块设计的核心是 “高内聚,低耦合”。 高内聚:一个模块只负责一个独立的、明确的职责(如:订单模块只处理所有订单相关业务)。模块内部的代码关联性非常强。 低耦合:模块与模块之间的依赖关系尽可能的简单和清晰。一个模块的变化,应该尽量减...
继续阅读 »

一. 分模块设计与开发


分模块设计的核心是 “高内聚,低耦合”。



  • 高内聚:一个模块只负责一个独立的、明确的职责(如:订单模块只处理所有订单相关业务)。模块内部的代码关联性非常强。

  • 低耦合:模块与模块之间的依赖关系尽可能的简单和清晰。一个模块的变化,应该尽量减少对其他模块的影响。


通过Maven,我们可以轻松地实现这一思想。每个模块都是一个独立的Maven项目,它们通过父子工程和依赖管理有机地组织在一起。



以一个经典的电商平台为例,我们可以将其拆分为以下模块:
在这里插入图片描述




将pojo和utils模块分出去



在这里插入图片描述
)



在tilas-web-management中的pom.xml中引入pojo,utils模块



<dependency>
<groupId>org.example</groupId>
<artifactId>tlias-pojo</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>tlias-utils</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

分模块的好处



  • 代码清晰,职责分明:每个开发人员可以专注于自己的模块,易于理解和维护。

  • 并行开发,提升效率:多个模块可以由不同团队并行开发,只需约定好接口即可。

  • 构建加速:Maven支持仅构建更改的模块及其依赖模块(使用 mvn -pl 命令),大大节省构建时间。

  • 极高的复用性:像 core、dao 这样的模块,可以直接被其他新项目引用,避免重复造轮子。

  • 便于单元测试:可以针对单个业务模块进行独立的、深入的测试。


二. 继承


2.1 继承配置


tlias-pojo、tlias-utils、tlias-web-management 中都引入了一个依赖 lombok 的依赖。我们在三个模块中分别配置了一次。
在这里插入图片描述


我们可以再创建一个父工程 tlias-parent ,然后让上述的三个模块 tlias-pojo、tlias-utils、tlias-web-management 都来继承这个父工程 。 然后再将各个模块中都共有的依赖,都提取到父工程 tlias-parent中进行配置,只要子工程继承了父工程,依赖它也会继承下来,这样就无需在各个子工程中进行配置了。
在这里插入图片描述



将tilas-parent中的pom.xml设置成pom打包方式



Maven打包方式:



  • jar:普通模块打包,springboot项目基本都是jar包(内嵌tomcat运行)

  • war:普通web程序打包,需要部署在外部的tomcat服务器中运行

  • pom:父工程或聚合工程,该模块不写代码,仅进行依赖管理


<packaging>pom</packaging>


通过parent来配置父工程



<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.8</version>
<!-- 父工程的pom.xml的相对路径 如果不配置就直接从中央仓库调取 -->
<relativePath/> <!-- lookup parent from repository -->
</parent>


子工程配置 通过relativePath配置父工程的路径



<parent>
<groupId>org.example</groupId>
<artifactId>tlias-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../tilas-parent/pom.xml</relativePath>
</parent>

2.2 版本锁定


如果项目拆分的模块比较多,每一次更换版本,我们都得找到这个项目中的每一个模块,一个一个的更改。 很容易就会出现,遗漏掉一个模块,忘记更换版本的情况。



在maven中,可以在父工程的pom文件中通过 来统一管理依赖版本。



<!--统一管理依赖版本-->
<dependencyManagement>
<dependencies>
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
······
</dependencies>
</dependencyManagement>


这样在子工程中就不需要进行version版本设置了



<dependencies>
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
</dependencies>


注意!!!



  • 在父工程中所配置的 dependencyManagement只能统一管理依赖版本,并不会将这个依赖直接引入进来。 这点和 dependencies 是不同的。

  • 子工程要使用这个依赖,还是需要引入的,只是此时就无需指定 版本号了,父工程统一管理。变更依赖版本,只需在父工程中统一变更。



2.3 自定义属性


<properties>
<lombok.version>1.18.34</lombok.version>
</properties>


通过${属性名}引用属性



<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>

三. 聚合



聚合(Aggregation) 和继承(Inheritance) 是Maven支持分模块设计的两个核心特性,它们目的不同,但又相辅相成。
简单来说,继承是为了统一管理,而聚合是为了统一构建。



在这里插入图片描述



聚合工程中通过modules>module来配置聚合



<!-- 聚合其他模块 -->
<modules>
<module>你要聚合的模块路径</module>
</modules>

四. 私服



私服是一种特殊的远程仓库,它代理并缓存了位于互联网的公共仓库(如MavenCentral),同时允许企业内部部署自己的私有构件(Jar包)。
你可以把它理解为一个 “架设在公司内网里的Maven中央仓库”。



在这里插入图片描述
项目版本说明:



  • RELEASE(发布版本):功能趋于稳定、当前更新停止,可以用于发行的版本,存储在私服中的RELEASE仓库中。

  • SNAPSHOT(快照版本):功能不稳定、尚处于开发中的版本,即快照版本,存储在私服的SNAPSHOT仓库中。


4.1 私服下载


下载Nexus私服 help.sonatype.com/en/download…
在这里插入图片描述



在D:\XXXXXXXX\bin目录下 cmd运行nexus /run nexus 显示这个就表示开启成功啦!



在这里插入图片描述



浏览器输入localhost:8081



在这里插入图片描述
私服仓库说明:



  • RELEASE:存储自己开发的RELEASE发布版本的资源。

  • SNAPSHOT:存储自己开发的SNAPSHOT发布版本的资源。

  • Central:存储的是从中央仓库下载下来的依赖


4.2 资源上传与下载



设置私服的访问用户名/密码(在自己maven安装目录下的conf/settings.xml中的servers中配置)



<server>
<id>maven-releases</id>
<username>admin</username>
<password>admin</password>
</server>

<server>
<id>maven-snapshots</id>
<username>admin</username>
<password>admin</password>
</server>



设置私服依赖下载的仓库组地址(在自己maven安装目录下的conf/settings.xml中的mirrors中配置)



<mirror>
<id>maven-public</id>
<mirrorOf>*</mirrorOf>
<url>http://localhost:8081/repository/maven-public/</url>
</mirror>


设置私服依赖下载的仓库组地址(在自己maven安装目录下的conf/settings.xml中的profiles中配置)



<profile>
<id>allow-snapshots</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>

<repositories>
<repository>
<id>maven-public</id>
<url>http://localhost:8081/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>

</profile>


IDEA的maven工程的pom文件中配置上传(发布)地址(直接在tlias-parent中配置发布地址)



<distributionManagement>
<!-- release版本的发布地址 -->
<repository>
<id>maven-releases</id>
<url>http://localhost:8081/repository/maven-releases/</url>
</repository>

<!-- snapshot版本的发布地址 -->
<snapshotRepository>
<id>maven-snapshots</id>
<url>http://localhost:8081/repository/maven-snapshots/</url>
</snapshotRepository>

</distributionManagement>


打开maven控制面板双击deploy



在这里插入图片描述
由于当前我们的项目是SNAPSHOT版本,所以jar包是上传到了snapshot仓库中


作者:纯真时光
来源:juejin.cn/post/7549363056862756918
收起阅读 »

小红书小组件开发 最早踩坑版

web
前言 是这样的,这段小红书逛的多,发现有一篇关于小红书小组件的介绍,介绍里提到的是[AI调酒]这款小组件,在内容里可以直接挂载。我试玩了一下,还挺有趣,交互感挺强的。 然后下面提到说,留言即可有机会获取内测开发资格,内测时可以免费使用里面的AI功能。 想着能...
继续阅读 »

前言



是这样的,这段小红书逛的多,发现有一篇关于小红书小组件的介绍,介绍里提到的是[AI调酒]这款小组件,在内容里可以直接挂载。我试玩了一下,还挺有趣,交互感挺强的。
然后下面提到说,留言即可有机会获取内测开发资格,内测时可以免费使用里面的AI功能。
想着能白嫖就报名了,正好加入小红书生态,好宣传自己的app一波,hhh


没想到过一天就受到了科技署的邀请,加入了内测群!


开发


进群后,就令我感到有点诧异了,群里陆续进去了21个人,有15个工作人员,6个被邀请的开发。进去后,组织者发了一个操作文档:


各位好,我们是小组件项目的产品和研发,各位可以先提供下自己的小红书账号,我们为大家添加测试白名单

添加后需要完成的事项:

1、前往小红书开放平台创建开发者账号(开白后可申请个人主体账号,若已有账号可忽略)
https://miniapp.xiaohongshu.com/home

2、查阅小组件和智能体的开发文档,下载开平IDE工具,进行设计和开发
小组件开发介绍:https://miniapp.xiaohongshu.com/doc/DC026740
智能体开发介绍(如果需要在小组件中内嵌AI服务):https:/
/miniapp.xiaohongshu.com/doc/DC783288
最佳实践:https:/
/miniapp.xiaohongshu.com/doc/DC246551

3、由于小组件是无需备案的,因此平台会承担一定风险,因此有明确的创作方向后,需要开发者提供简易的demo图,我们会做内部的产品&研发&安全的可行性评估

🌟🌟 在过程中,大家如果有流程、开发、设计上的问题,都可以群内和我们沟通,由于这是小组件第一波内测,所以不可避免地可能还有些问题,大家提出来后我们也会及时处理优化,也感谢大家理解[合十]

🌟🌟 也辛苦各位重点关注:小组件整体的定位是「轻量、简单」,以及整体的UI设计也希望能「简介、美观」,更符合小红书社区氛围,这样更容易在社区总传播


意思就是,大家按照文档操作就行,基本没什么大问题。


但是小问题还是挺多的。


里面的很多开发,基本都有小红书小程序的开发经验了,这次感觉纯属了为了捧场或者是和我一样,做完后有没有什么推流。他们基本上很快就做完了。
我就不一样了,有主职工作而且一直做的是移动原生,虽然之前学过一丢丢微信小程序开发,但都已经过了差不多3年了。


但来都来了。


小组件



小组件开发可以独立进行,不依赖其他的三方。如果可以的话,你可以开发个很简易的demo上去,当然你还得经过小红书官方的审核,如果太基础的话就不太行,这个大家都懂得。


一般来讲,小组件需要依赖后台服务,或者是小红书他们提供AI智能体,毕竟咋们是奔着它去的。而且给的demo也是关于智能体。


第一步下载编辑器

编辑器
编辑器好像是需要这个版本才行,是官方人员直接在群里发的。最新版本的编辑器融合了AI功能,真的很给力,我自己写好了核心的逻辑后,让它来美化UI真是太省事了,而且美化的UI和小红书官方的小组件交互效果有点类似,有点红薯风,对于我这种没有UI审美的开发来说是一大福星,而且比免费版本的cursor好用。


跟着文档开发后

跟着文档开发,这里就不贴具体的过程了,因为文档也会更新,会更完善。因为我们是第一版本,所以文档里有很多遗漏的和错误的,这里补充一下这部分,如果再有人开发到这一步,可能会用的到。


隐私协议:


xhs.openClipLegalPolicy();

小组件核心代码
因为我是调用的是智能体,调用智能体的代码用官方的那样写是有问题的,写了好久有跑不通,咨询了很久才得到正确的代码:


初始化agent


  // 初始化 Agent  env: 'production'按需选择线上和测试
async initAgent() {

try {
const agentId = "test6baffa154e6db2d96e64ef310a6e";
const agent = xhs.cloud.AI.createAgent({
agentId,
env: 'production',
version: '0.1.8'
});

this.setData({
agent: agent
});

console.log("Agent初始化成功");
} catch (error) {
console.error("Agent初始化失败: ", error);
xhs.showToast({
title: "Agent初始化失败",
icon: "none",
});
}
},

// 调用智能体 解梦
if (this.data.agent == null) return;

const agentInfo = this.data.agent.getAgentInfo();
console.log("res", agentInfo);

// 使用回调方式发送消息
const { message, onMessage, onSuccess, onError } = this.data.agent.sendMessage({
msg: dreamContent,
history: [],
});


onSuccess((result) => {
this.setData({
isOver:true
})
console.log("请求成功:", result);
console.log("API调用成功,返回结果:", result);

// result.data.data
xhs.hideLoading();

});

// 监听流式消息
onMessage((chunkStr) => {
// console.log("收到消息块:", chunkStr, "api-message", message);
xhs.hideLoading();

if (chunkStr === "[DONE]") {
return;
}

let chunk = null;
try {
chunk = JSON.parse(chunkStr);
} catch (error) {
console.error("解析消息块失败:", error);
return;
}

// 解析消息块
if (chunk!=null&&chunk.choices && chunk.choices[0] && chunk.choices[0].message) {
const message = chunk.choices[0].message;

console.log("收到消息块 message:", message.content);


// 处理回复内容
if (message.content) {
this.setData({
dreamInterpretation:this.data.dreamInterpretation + message.content
})


this.setData({
isLoading: false,
showResult: true,
resultDream: dreamContent,
dreamInterpretation: this.data.dreamInterpretation
});

}
}
});

// 监听错误回调
onError((error) => {
console.error("请求失败:", error);
xhs.hideLoading();
xhs.showToast({
title: "生成失败,请重试",
icon: "none",
});
});

智能体



智能体分为流式和非流式的。
看具体的业务需求了,如果是很快的生成 和 生成的文本很短,就像[AI调酒],只需要简单的json即可,那就可以用非流式的。
像我这种需要生成长文本的就有点不太适合了,所以这里选择的是流式输出的形式。


核心代码:


// {user_mood: '开心',user_taste: '随便'}
async sendMessage(input) {
console.log('message -- '+ JSON.stringify(input))
console.log('--')
console.log('msg -- '+ input.msg)

const model = this.createModel('deepseek-v3')

const messages = [
{
role:'system',
content: systemPrompt
},
{
role:'user',
content:{
type:'text',
content: input.msg
// content: JSON.stringify(input)
// content: '{\'user_mood\': \'开心\', \'user_taste\': \'随便\'}'
}
}
]

const modelResponse = await model.streamText({
enable_thinking:false,
temperature:1,
messages: [
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: {
type: 'text',
// JSON.stringify(input.msg)
content: JSON.stringify(input)
// context:input.msg
}
}
]}
)
console.log('aaaa')
for await (const chunk of modelResponse) {
this.sseSender.send({ data: chunk });
}
console.log('bbbb')
this.sseSender.end();
}

systemPrompt 指的是提示词,提示词是ai的核心,这里可以返回json或者是markdown或是html样式。如果不会写提示词,也可以让ai给你写提示词,hhh


End



最后附一张截图。各位大佬有兴趣可以在小红书里搜索AI解梦小组件 ,里面有笔记进行挂载。


发布后发现,官方根本没有推流,而且后续也没提小组件这回事了🤡。
不过就当自己玩玩了,可以使用免费的ai服务进行快速解梦~


作者:景彬
来源:juejin.cn/post/7564540677478301759
收起阅读 »

别再被VO、BO、PO、DTO、DO绕晕!今天用一段代码把它们讲透

大家好,我是晓凡。前阵子晓凡的粉丝朋友面试,被问到“什么是VO?和DTO有啥区别?”粉丝朋友:“VO就是Value Object,DTO就是Data Transfer Object……”面试官点点头:“那你说说,一个下单接口里,到底哪个算VO,哪个算DTO?”...
继续阅读 »

大家好,我是晓凡。

前阵子晓凡的粉丝朋友面试,被问到“什么是VO?和DTO有啥区别?”

粉丝朋友:“VO就是Value Object,DTO就是Data Transfer Object……”

面试官点点头:“那你说说,一个下单接口里,到底哪个算VO,哪个算DTO?”

粉丝朋友有点犹豫了。

回来后粉丝朋友痛定思痛,把项目翻了个底朝天,并且把面试情况告诉了晓凡,下定决心捋清楚了这堆 XO 的真实含义。

于是乎,这篇文章就来了 今天咱们就用一段“用户下单买奶茶”的故事,把 VO、BO、PO、DTO、DO 全部聊明白。

看完保准你下次面试不卡壳,写代码不纠结。


一、先放结论

它们都是“为了隔离变化”而诞生的马甲

缩写英文全称中文直译出现位置核心目的
POPersistent Object持久化对象数据库 ↔ 代码一张表一行记录的直接映射
DODomain Object领域对象核心业务逻辑层充血模型,封装业务行为
BOBusiness Object业务对象应用/服务层聚合多个DO,面向用例编排
DTOData Transfer Object数据传输对象进程/服务间精简字段,抗网络延迟
VOView Object视图对象控制层 ↔ 前端展示友好,防敏感字段泄露

一句话总结: PO 管存储,DO 管业务,BO 管编排,DTO 管网络,VO 管界面。

下面上代码,咱们边喝奶茶边讲。


二、业务场景

用户下一单“芋泥波波奶茶”

需求:

  1. 用户选好规格(大杯、少冰、五分糖)。
  2. 点击“提交订单”,前端把数据发过来。
  3. 后端算价格、扣库存、落库,返回“订单创建成功”页面。

整条链路里,我们到底需要几个对象?


三、从数据库开始:PO

PO是Persistent Object的简写 PO 就是“一行数据一个对象”,字段名、类型和数据库保持一一对应,不改表就不改它。

// 表:t_order
@Data
@TableName("t_order")
public class OrderPO {
   private Long id;          // 主键
   private Long userId;      // 用户ID
   private Long productId;   // 商品ID
   private String sku;       // 规格JSON
   private BigDecimal price; // 原价
   private BigDecimal payAmount; // 实付
   private Integer status;   // 订单状态
   private LocalDateTime createTime;
   private LocalDateTime updateTime;
}

注意:PO 里绝不能出现业务方法,它只是一个“数据库搬运工”。


四、核心业务:DO

DO 是“有血有肉的对象”,它把业务规则写成方法,让代码自己说话。

// 领域对象:订单
public class OrderDO {

   private Long id;
   private UserDO user;      // 聚合根
   private MilkTeaDO milkTea; // 商品
   private SpecDO spec;      // 规格
   private Money price;      // Money是值对象,防精度丢失
   private OrderStatus status;

   // 业务方法:计算最终价格
   public Money calcFinalPrice() {
       // 会员折扣
       Money discount = user.getVipDiscount();
       // 商品促销
       Money promotion = milkTea.getPromotion(spec);
       return price.minus(discount).minus(promotion);
  }

   // 业务方法:下单前置校验
   public void checkBeforeCreate() {
       if (!milkTea.hasStock(spec)) {
           throw new BizException("库存不足");
      }
  }
}

DO 可以引用别的 DO,形成聚合根。它不关心数据库,也不关心网络。


五、面向用例:BO

BO 是“场景大管家”,把多个 DO 攒成一个用例,常出现在 Service 层。

@Service
public class OrderBO {

   @Resource
   private OrderRepository orderRepository; // 操作PO
   @Resource
   private InventoryService inventoryService; // RPC或本地
   @Resource
   private PaymentService paymentService;

   // 用例:下单
   @Transactional
   public OrderDTO createOrder(CreateOrderDTO cmd) {

       // 1. 构建DO
       OrderDO order = OrderAssembler.toDO(cmd);

       // 2. 执行业务校验
       order.checkBeforeCreate();

       // 3. 聚合逻辑:扣库存、算价格
       inventoryService.lock(order.getSpec());
       Money payAmount = order.calcFinalPrice();

       // 4. 落库
       OrderPO po = OrderAssembler.toPO(order, payAmount);
       orderRepository.save(po);

       // 5. 返回给前端需要的数据
       return OrderAssembler.toDTO(po);
  }
}

BO 的核心是编排,它把 DO、外部服务、PO 串成一个完整的业务动作。


六、跨进程/服务:DTO

DTO 是“网络快递员”,字段被压缩成最少,只带对方需要的数据。

1)入口 DTO:前端 → 后端

@Data
public class CreateOrderDTO {
   @NotNull
   private Long userId;
   @NotNull
   private Long productId;
   @Valid
   private SpecDTO spec; // 规格
}

2)出口 DTO:后端 → 前端

@Data
public class OrderDTO {
   private Long orderId;
   private String productName;
   private BigDecimal payAmount;
   private String statusDesc;
   private LocalDateTime createTime;
}

DTO 的字段命名常带 UI 友好词汇(如 statusDesc),并且绝不暴露敏感字段(如 userId 在返回给前端时可直接省略)。


七、最后一步:VO

VO 是“前端专属快递”,字段可能二次加工,甚至带 HTML 片段。

@Data
public class OrderVO {
   private String orderId; // 用字符串避免 JS long 精度丢失
   private String productImage; // 带 CDN 前缀
   private String priceText; // 已格式化为“¥18.00”
   private String statusTag; // 带颜色:green/red
}

VO 通常由前端同学自己写 TypeScript/Java 类,后端只负责给 DTO,再让前端 BFF 层转 VO。如果你用 Node 中间层或 Serverless,VO 就出现在那儿。


八、一张图记住流转过程

前端页面
  │ JSON
  ▼
CreateOrderVO (前端 TS)
  │ 序列化
  ▼
CreateOrderDTO (后端入口)
  │ BO.createOrder()
  ▼
OrderDO (充血领域模型)
  │ 聚合、计算
  ▼
OrderPO (落库)
  │ MyBatis
  ▼
数据库

返回时反向走一遍:

数据库
  │ SELECT
OrderPO
  │ 转换
OrderDTO
  │ JSON
OrderVO (前端 TS 渲染)

九、常见疑问答疑

  1. 为什么 DO 和 PO 不合并? 数据库加索引、加字段不影响业务;业务改规则不改表结构。隔离变化。
  2. DTO 和 VO 能合并吗? 小项目可以,但一上微服务或多端(App、小程序、管理后台),立马爆炸。比如后台需要用户手机号,App 不需要,合并后前端会拿到不该看的数据。
  3. BO 和 Service 有什么区别? BO 更贴近用例,粒度更粗。Service 可能细分读写、缓存等。命名随意,关键看团队约定。

十、一句话背下来

数据库里叫 PO,业务里是 DO,编排靠 BO,网络走 DTO,前端看 VO。

下次面试官再问,你就把奶茶故事讲给他听,保证他频频点头。

本期内容到这儿就结束了

我是晓凡,再小的帆也能远航

我们下期再见 ヾ(•ω•`)o (●'◡'●)


作者:程序员晓凡
来源:juejin.cn/post/7540472612595941422
收起阅读 »