Android - 你可能需要这样一个日志库
前言
目前大多数库api设计都是Log.d("tag", "msg")
这种风格,而且支持自定义日志存储的比较少,
所以作者想自己造一个轮子。
这种api风格有什么不好呢?
首先,它的tag
是一个字符串,需要开发人员严格管理tag
,要不然可能各种硬编码的tag
满天飞。
另外,它也可能导致性能陷阱,假设有这么一段代码:
// 打印一个List
Log.d("tag", list.joinToString())
此处使用Debug
打印日志,生产模式下调高日志等级,不打印这一行日志,但是list.joinToString()
这一行代码仍然会被执行,有可能导致性能问题。
下文会分析作者期望的api是什么样的,本文演示代码都是用kotlin
,库中好用的api也是基于kotlin
特性来实现的。
作者写库有个习惯,对外开放的类或者全局方法都会加一个前缀f
,一个是为了避免命名冲突,另一个是为了方便代码检索,以下文章中会出现,这里做一下解释。
期望
什么样的api才能解决上面的问题呢?我们看一下方法的签名和打印方式
inline fun <reified T : FLogger> flogD(block: () -> Any)
interface AppLogger : FLogger
flogD<AppLogger> {
list.joinToString { it }
}
flogD
方法打印Debug
日志,传一个Flogger
的子类AppLogger
作为日志标识,同时传一个block
来返回要打印的日志内容。
日志标识是一个类或者接口,所以管理方式比较简单不会造成tag
混乱的问题,默认tag
是日志标识类的短类名。生产模式下调高日志等级后,block
就不会被执行了,避免了可能的性能问题。
实现分析
日志库的完整实现已经写好了,放在这里xlog
- 支持限制日志大小,例如限制每天只能写入10MB的日志
- 支持自定义日志格式
- 支持自定义日志存储,即如何持久化日志
这一节主要分析一下实现过程中遇到的问题。
问题:如果App运行期间日志文件被意外删除了,怎么处理?
在Android中,用java.io
的api对一个文件进行写入,如果文件被删除,继续写入的话不会抛异常,这样会导致日志丢失,该如何解决?
有同学说,在写入之前先检查文件是否存在,如果存在就继续写入,不存在就创建后写入。
检查一个文件是否存在通常是调用java.io.File.exist()
方法,但是它比较耗性能,我们来做一个测试:
measureTime {
repeat(1_0000) {
file.exists()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
14:50:33.536 MainActivity com.sd.demo.xlog I time:39
14:50:35.872 MainActivity com.sd.demo.xlog I time:54
14:50:38.200 MainActivity com.sd.demo.xlog I time:43
14:50:40.028 MainActivity com.sd.demo.xlog I time:53
14:50:41.693 MainActivity com.sd.demo.xlog I time:58
可以看到1万次调用的耗时在50毫秒左右。
我们再测试一下对文件写入的耗时:
val output = filesDir.resolve("log.txt").outputStream().buffered()
val log = "1".repeat(50).toByteArray()
measureTime {
repeat(1_0000) {
output.write(log)
output.flush()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
14:57:56.092 MainActivity com.sd.demo.xlog I time:38
14:57:56.558 MainActivity com.sd.demo.xlog I time:57
14:57:57.129 MainActivity com.sd.demo.xlog I time:57
14:57:57.559 MainActivity com.sd.demo.xlog I time:46
14:57:58.054 MainActivity com.sd.demo.xlog I time:54
可以看到1万次调用,每次写入50个字符的耗时也在50毫秒左右。如果每次写入日志前都判断一下文件是否存在,那么实际上相当于2次写入的性能成本,这显然很不划算。
还有同学说,开一个线程,定时判断文件是否存在,这样子虽然不会损耗单次写入的性能,但是又多占用了一个线程资源,显然也不符合作者的需求。
其实Android已经给我们提供了这种场景的解决方案,那就是android.os.MessageQueue.IdleHandler
,关于IdleHandler
这里就不展开讨论了,简单来说就是当你在主线程注册一个IdleHandler
后,它会在主线程空闲的时候被执行。
我们可以在每次写入日志之后注册IdleHandler
,等IdleHandler
被执行的时候检查一下日志文件是否存在,如果不存在就关闭输出流,这样子在下一次写入的时候就会重新创建文件写入了。
这里要注意每次写入日志之后注册IdleHandler
,并不是每次都创建新对象,要判断一下如果原先的对象还未执行的话就不用注册一个新的IdleHandler
,库中大概的代码如下:
private class LogFileChecker(private val block: () -> Unit) {
private var _idleHandler: IdleHandler? = null
fun register(): Boolean {
// 如果当前线程没有Looper则不注册,上层逻辑可以直接检查文件是否存在,因为是非主线程
Looper.myLooper() ?: return false
// 如果已经注册过了,直接返回
_idleHandler?.let { return true }
val idleHandler = IdleHandler {
// 执行block检查任务
libTryRun { block() }
// 重置变量,等待下次注册
_idleHandler = null
false
}
// 保存并注册idleHandler
_idleHandler = idleHandler
Looper.myQueue().addIdleHandler(idleHandler)
return true
}
}
这样子文件被意外删除之后,就可以重新创建写入了,避免丢失大量的日志。
问题:如何检测文件大小是否溢出
库支持对每天的日志大小做限制,例如限制每天最多只能写入10MB,每次写入日志之后都会检查日志大小是否超过限制,通常我们会调用java.io.File.length()
方法获取文件的大小,但是它也比较耗性能,我们来做一个测试:
val file = filesDir.resolve("log.txt").apply {
this.writeText("hello")
}
measureTime {
repeat(1_0000) {
file.length()
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:56:04.090 MainActivity com.sd.demo.xlog I time:61
16:56:05.329 MainActivity com.sd.demo.xlog I time:80
16:56:06.382 MainActivity com.sd.demo.xlog I time:72
16:56:07.496 MainActivity com.sd.demo.xlog I time:79
16:56:08.591 MainActivity com.sd.demo.xlog I time:78
可以看到耗时在60毫秒左右,相当于上面测试中1次文件写入的耗时。
库中支持自定义日志存储,在日志存储接口中定义了size()
方法,上层通过此方法来判断当前日志的大小。
如果自定义了日志存储,避免在此方法中每次调用java.io.File.length()
来返回日志大小,应该维护一个表示日志大小的变量,变量初始化的时候获取一下java.io.File.length()
,后续通过写入的数量来增加这个变量的值,并在size()
方法中返回。库中默认的日志存储实现类就是这样实现的,有兴趣的可以看这里
问题:文件大小溢出后怎么处理?
假设限制每天最多只能写入10MB,那超过10MB后如何处理?有同学说直接删掉或者清空文件,重新写入,这也是一种策略,但是会丢失之前的所有日志。
例如白天写了9.9MB,到晚上的时候写满10MB,清空之后,白天的日志都没了,这时候用户反馈白天遇到的一个bug,需要上传日志,那就芭比Q了。
有没有办法少丢失一些呢?可以把日志分多个文件存储,为了便于理解假设分为2个文件存储,一天10MB,那1个文件最多只能写入5MB。具体步骤如下:
- 写入文件
20231128.log
20231128.log
写满5MB的时候关闭输出流,并把它重命名为20231128.log.1
这时候继续写日志的话,发现20231128.log
文件不存在就会创建,又跳到了步骤1,就这样一直重复1和2两个步骤,到晚上写满10MB的时候,至少还有5MB的日志内容保存在20231128.log.1
文件中避免丢失全部的日志。
分的文件数量越多,保留的日志就越多,实际上就是拿出一部分空间当作中转区,满了就向后递增数字重命名备份。目前库中只分为2个文件存储,暂时不开放自定义文件数量。
问题:打印日志的性能
性能,是这个库最关心的问题,通常来说文件写入操作是性能开销的大头,目前是用java.io
相关的api来实现的,怎样提高写入性能作者也一直在探索,在demo中提供了一个基于内存映射的日志存储方案,但是稳定性未经测试,后续测试通过后可能会转正。有兴趣的读者可以看看这里。
还有一个比较影响性能的就是日志的格式化,通常要把一个时间戳转为某个日期格式,大部分人都会用java.text.SimpleDateFormat
来格式化,用它来格式化年:月:日
的时候问题不大,但是如果要格式化时:分:秒.毫秒
那它就比较耗性能,我们来做一个测试:
val format = SimpleDateFormat("HH:mm:ss.SSS")
val millis = System.currentTimeMillis()
measureTime {
repeat(1_0000) {
format.format(millis)
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:05:26.920 MainActivity com.sd.demo.xlog I time:245
16:05:27.586 MainActivity com.sd.demo.xlog I time:227
16:05:28.324 MainActivity com.sd.demo.xlog I time:212
16:05:29.370 MainActivity com.sd.demo.xlog I time:217
16:05:30.157 MainActivity com.sd.demo.xlog I time:193
可以看到1万次格式化耗时大概在200毫秒左右。
我们再用java.util.Calendar
测试一下:
val calendar = Calendar.getInstance()
// 时间戳1
val millis1 = System.currentTimeMillis()
// 时间戳2
val millis2 = millis1 + 1000
// 切换时间戳标志
var flag = true
measureTime {
repeat(1_0000) {
calendar.timeInMillis = if (flag) millis1 else millis2
calendar.run {
"${get(Calendar.HOUR_OF_DAY)}:${get(Calendar.MINUTE)}:${get(Calendar.SECOND)}.${get(Calendar.MILLISECOND)}"
}
flag = !flag
}
}.let {
Log.i("MainActivity", "time:${it.inWholeMilliseconds}")
}
16:11:25.342 MainActivity com.sd.demo.xlog I time:35
16:11:26.209 MainActivity com.sd.demo.xlog I time:35
16:11:27.316 MainActivity com.sd.demo.xlog I time:37
16:11:28.057 MainActivity com.sd.demo.xlog I time:25
16:11:28.825 MainActivity com.sd.demo.xlog I time:18
这里解释一下为什么要用两个时间戳,因为Calendar
内部有缓存,如果用同一个时间戳测试的话,没办法评估它真正的性能,所以这里每次格式化之后就切换到另一个时间戳,避免缓存影响测试。
可以看到1万次的格式化耗时在30毫秒左右,差距很大。如果要自定义日志格式的话,建议用Calendar
来格式化时间,有更好的方案欢迎和作者交流。
问题:日志的格式如何显示
手机的存储资源是宝贵的,如何定义日志格式也是一个比较重要的细节。
- 优化时间显示
目前库内部是以天为单位来命名日志文件的,例如:20231128.log
,所以在格式化时间戳的时候只保留了时:分:秒.毫秒
,避免冗余显示当天的日期。
- 优化日志等级显示
打印的时候提供了4个日志等级:Verbose, Debug, Info, Warning, Error
,一般最常用的记录等级是Info
,所以在格式化的时候如果等级是Info
则不显示等级标志,规则如下:
private fun FLogLevel.displayName(): String {
return when (this) {
FLogLevel.Verbose -> "V"
FLogLevel.Debug -> "D"
FLogLevel.Warning -> "W"
FLogLevel.Error -> "E"
else -> ""
}
}
- 优化日志标识显示
如果连续2条或多条日志都是同一个日志标识,那么就只有第1条日志会显示日志tag
- 优化线程ID显示
如果是主线程的话,不显示线程ID,只有非主线程才显示线程ID
经过上面的优化之后,日志打印的格式是这样的:
flogI<AppLogger> { "1" }
flogI<AppLogger> { "2" }
flogW<AppLogger> { "3" }
flogI<UserLogger> { "user debug" }
thread {
flogI<UserLogger> { "thread" }
}
19:19:43.961[AppLogger] 1
19:19:43.974 2
19:19:43.975[W] 3
19:19:43.976[UserLogger] user debug
19:19:43.977[12578] thread
API
这一节介绍一下库的API,调用FLog.init()
方法初始化,初始化如果不想打印日志,可以调用FLog.setLevel(FLogLevel.Off)
关闭日志
常用方法
// 初始化
FLog.init(
//(必传参数)日志文件目录
directory = filesDir.resolve("app_log"),
//(可选参数)自定义日志格式
formatter = AppLogFormatter(),
//(可选参数)自定义日志存储
storeFactory = AppLogStoreFactory(),
//(可选参数)是否异步发布日志,默认值false
async = false,
)
// 设置日志等级 All, Verbose, Debug, Info, Warning, Error, Off 默认日志等级:All
FLog.setLevel(FLogLevel.All)
// 限制每天日志文件大小(单位MB),小于等于0表示不限制大小,默认限制每天日志大小100MB
FLog.setLimitMBPerDay(100)
// 设置是否打打印控制台日志,默认打开
FLog.setConsoleLogEnabled(true)
/**
* 删除日志,参数saveDays表示要保留的日志天数,小于等于0表示删除全部日志,
* 此处saveDays=1表示保留1天的日志,即保留当天的日志
*/
FLog.deleteLog(1)
打印日志
interface AppLogger : FLogger
flogV<AppLogger> { "Verbose" }
flogD<AppLogger> { "Debug" }
flogI<AppLogger> { "Info" }
flogW<AppLogger> { "Warning" }
flogE<AppLogger> { "Error" }
// 打印控制台日志,不会写入到文件中,不需要指定日志标识,tag:DebugLogger
fDebug { "console debug log" }
配置日志标识
可以通过FLog.config
方法修改某个日志标识的配置信息,例如下面的代码:
FLog.config<AppLogger> {
// 修改日志等级
this.level = FLogLevel.Debug
// 修改tag
this.tag = "AppLoggerAppLogger"
}
自定义日志格式
class AppLogFormatter : FLogFormatter {
override fun format(record: FLogRecord): String {
// 自定义日志格式
return record.msg
}
}
interface FLogRecord {
/** 日志标识 */
val logger: Class<out FLogger>
/** 日志tag */
val tag: String
/** 日志内容 */
val msg: String
/** 日志等级 */
val level: FLogLevel
/** 日志生成的时间戳 */
val millis: Long
/** 日志是否在主线程生成 */
val isMainThread: Boolean
/** 日志生成的线程ID */
val threadID: String
}
自定义日志存储
日志存储是通过FLogStore
接口实现的,每一个FLogStore
对象负责管理一个日志文件。
所以需要提供一个FLogStore.Factory
工厂为每个日志文件提供FLogStore
对象。
class AppLogStoreFactory : FLogStore.Factory {
override fun create(file: File): FLogStore {
return AppLogStore(file)
}
}
class AppLogStore(file: File) : FLogStore {
// 添加日志
override fun append(log: String) {}
// 返回当前日志的大小
override fun size(): Long = 0
// 关闭
override fun close() {}
}
结束
库目前还处于alpha
阶段,如果有遇到问题可以及时反馈给作者,最后感谢大家的阅读。
作者邮箱:565061763@qq.com
来源:juejin.cn/post/7306423214493270050
Flutter 为什么没有一款好用的UI框架?
哈喽,我是老刘
前两天,系统给我推送了一个问题。
我理解提问者真正想问的是:有没有一个不用学习那么多UI组件和渲染知识,可以简单快速搭建UI的东西。
Flutter 包括原生开发,为什么需要考虑那么多细节,不能做的简单一些?
首先,我们需要明白Flutter的定位。
Flutter不是一个简单的甜品,而是一个能支撑大型系统开发的工程级框架。
这种定位和原生框架的定位是相当的。
因此,它要求整个框架有足够的灵活性,能适用于尽可能多的场景。
那么,如何提供足够的灵活性呢?
答案是让整个框架尽可能多的细节是可控的。
这就需要把整个框架的功能拆分的更细,提供的配置项足够多。
然而,这样的缺点就是开发起来会比较麻烦,需要控制很多细节。
因此,我们可以看到Flutter的组件拆分的很细,甚至有类似Padding这样专门负责缩进的组件,而且每个组件都有很多的配置参数。
Flutter配合Material组件库本身本就非常优秀的UI框架
虽然Flutter的灵活性带来了开发上的复杂性,但Flutter配合Material组件库本身就是一个非常优秀的UI框架。
Material组件库提供了丰富的预设组件,这些组件遵循Material Design指南,可以帮助开发者快速搭建出既美观又符合设计规范的UI界面。
使用Material组件库,开发者可以不必从头开始设计每一个UI元素,而是可以直接使用现成的组件,如按钮、对话框、卡片等,这些组件都有良好的交互和动画效果。
此外,Material组件库还提供了主题支持,开发者可以通过简单的配置,快速应用统一的风格到整个应用中。
因此,虽然Flutter的灵活性可能让初学者感到有些复杂,但配合Material组件库,Flutter实际上提供了一个非常高效和优秀的UI开发体验。
大型项目的正确打开方式
即便是Material组件库,它的设计是需要考虑应对各种不同类型app开发的,但是针对一个具体的项目,我们大多数时候不需要这样高的灵活性。
所以,这种情况下直接用Flutter提供的组件效率会比较低。
解放方法就是针对特定的项目做组件封装。
以我目前维护的项目为例,我们项目中所有的对话框都是相同的偏绿色调,圆角半径20,按钮大小固定,标题、详情的字体、字号也固定。
简单来说,就是所有的UI细节都是固定的,只是不同的dialog需要填充的文字不同。
这时候,我们就会定义一个自己的Dialog组件,只需要使用者传入标题和内容,以及设置按钮的回调即可。
UI的其他地方也是如此,比如页面框架、在多个页面都能用到的用户卡片、商品卡片等等。
当你的整个App大部分都是基于这些自定义组件进行搭积木式的开发,那开发效率是不是比找一些通用的UI框架更高呢?
总结
总而言之,Flutter因为它的工程级框架定位需要提供高度的灵活性,而这往往会导致开发细节的复杂性。
但是,通过针对具体项目的组件封装,我们可以大大提高开发效率,同时保持UI的一致性和项目的特定需求。
所以,与其寻找一个通用的UI框架,不如根据项目的具体需求进行自定义组件的开发。
如果看到这里的同学有学习Flutter的兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》
来源:juejin.cn/post/7387001928209170447
Flutter大型项目架构:分层设计篇
上篇文章讲的是状态管理(传送门)提到了 Flutter BLoC
,相比与原生的 setState()
及Provider
等有哪些优缺点,并结合实际项目写了一个简单的使用,接下来本篇文章来讲 Flutter
大型项目是如何进行分层设计的,费话不多说,直接进入正题哈。
为啥需要分层设计
其实这个没有啥固定答案,也许只是因为某一天看到手里的代码如同屎山一样,如下图,而随着业务功能的增加,不停的往这上面堆,这个屎山也会愈发庞大和混乱,如果这样继续下去,直到某一天因为一个小小的Bug,你需要花半天的时间来排查问题出在哪里,最后当你觉得问题终于改好了的时候,却不料碰了不该碰的地方,结果就是 fixing 1 bug will create 10 new bugs
,甚至程序的崩溃。
随着这种问题的凸显,于是团队里的显眼包A提出了要求团队里的每个人都必须负责完成给自己写的代码添加注释和文档,规范命名等措施,一段时间后,发现代码是规范了,但问题依然存在,这时候才发现如果工程的架构分层没有做好,再规范的代码和注释也只是在屎山上雕花,治标不治本而已。
请原谅我打了一个这么俗的比方,但话糙理不糙,那么啥是应用的分层设计呢?
简单的来说,应用的分层设计是一种将应用程序划分为不同层级的方法,每个层级负责特定的功能或责任。其中表示层(Presentation Layer
)负责用户界面和用户交互,将数据呈现给用户并接收用户输入;业务逻辑层(Business Logic Layer
)处理应用程序的业务逻辑,包括数据验证、处理和转换;数据访问层(Data Access Layer
)负责与数据存储交互,包括数据库或文件系统的读取和写入操作。
这样做有什么好处呢?一句话总结就是为了让代码层级责任清晰,维护、扩展和重用方便,每个模块能独立开发、测试和修改。
App
原生开发的分层设计
说到 iOS
、Android
的分层设计,就会想到如 MVC
、MVVM
等,它们主要是围绕着控制器层(Controller
)、视图层(View
)、和数据层(Model
),还有连接 View
和 Model
之间的模型视图层(ViewModel
)这些来讲的。
然而,MVC
、MVVM
概念还不算完整的分层架构,它们只是关注的 App
分层设计当中的应用层(Applicaiton Layer
)组织方式,对于一个简单规模较小的App来说,可能单单一个应用层就能搞定,不用担心业务增量和复杂度上升对后期开发的压力,而一旦 App
上了规模之后就有点应付不过来了。
当 App
有了一定规模之后,必然会涉及到分层的设计,还有模块化、Hybrid
机制、数据库、跨项目开发等等,拿 iOS
的原生分层设计落地实践来说,通常会将工程拆分成多个Pod
私有库组件,拆分的标准视情况而定,每一个分层组件是独立的开发和测试,再在主工程添加 Pod
私有库依赖来做分层设计开发。
此处应该有 Pod
分层组件化设计的配图,但是太懒了,就没有一个个的去搭建新项目和 Pod
私有库,不过 iOS
原生分层设计不是本篇文章的重点,本篇主要谈论的是 Flutter App
的分层设计。
Flutter
的分层设计
分层架构设计的理念其实是相通的,差别在于语言的特性和具体项目实施上,Flutter
项目也是如此。试想一下,当各种逻辑混合在一次的时候,即便是选择了像 Bloc
这样的状态管理框架来隔离视图层和逻辑实现层,也很难轻松的增强代码的拓展性,这时候选择采用一个干净的分层架构就显得尤为重要,怎样做到这一点呢,就需要将代码分成独立的层,并依赖于抽象而不是具体的实现。
Flutter App
想要实现分层设计,就不得不提到包管理工具,如果在将所有分层组件代码放在主工程里面,那样并不能达到每个组件单独开发、维护和测试的目的,而如果放在新建的 Dart Package
中,没发跨多个组件改代码和测试,无法实现本地包链接和安装。使用 melos 就能解决这个问题,类似于 iOS
包管理工具 Pod
, 而 melos
是 Flutter
项目的包管理工具。
组件包管理工具
- 安装
Melos
,将Melos
安装为全局包,这样整个系统环境都可以使用:
dart pub global activate melos
- 创建
workspace
文件夹,我这里命名为flutter_architecture_design
,添加melos
的配置文件melos.yaml
和pubspec.yaml
,其目录结构大概是这样的:
flutter_architecture_design
├── melos.yaml
├── pubspec.yaml
└── README.md
- 新建组件,以开发工具
Android Studio
为例,选择File
->New
->New Flutter Project
,根据需要创建组件包,需要注意的是组件包存放的位置要放在workspace
目录中。
- 编辑
melos.yaml
配置文件,将上一步新建的组件包名放在packages
之下,添加scripts
相关命令,其目的请看下一步:
name: flutter_architecture_design
packages:
- widgets/**
- shared/**
- data/**
- initializer/**
- domain/**
- resources/**
- app/**
command:
bootstrap:
usePubspecOverrides: true
scripts:
analyze:
run: dart pub global run melos exec --flutter "flutter analyze --no-pub --suppress-analytics"
description: Run analyze.
pub_get:
run: dart pub global run melos exec --flutter "flutter pub get"
description: pub get
build_all:
run: dart pub global run melos exec --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build all modules.
build_data:
run: dart pub global run melos exec --fail-fast --scope="*data*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build data module.
build_domain:
run: dart pub global run melos exec --fail-fast --scope="*domain*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build domain module.
build_app:
run: dart pub global run melos exec --fail-fast --scope="*app*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build app module.
build_shared:
run: dart pub global run melos exec --fail-fast --scope="*shared*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build shared module.
build_widgets:
run: dart pub global run melos exec --fail-fast --scope="*widgets*" --depends-on="build_runner" "flutter packages pub run build_runner build --delete-conflicting-outputs"
description: build_runner build shared module.
- 打开命令行,切换到
workspace
目录,也就是flutter_architecture_design
目录,执行命令。
melos bootstrap
出现
SUCCESS
之后,现在的目录结构是这样的:
- 点击
Android Studio
的add configuration
,将下图中的Shell Scripts
选中后点击OK
。
以上的
Scripts
添加完后就可以在这里看到了,操作起来也很方便,不需要去命令行那里执行命令。
Flutter
分层设计实践
接下来介绍一下上面创建的几个组件库。
app
:项目的主工程,存放业务逻辑代码、UI
页面和Bloc
,还有styles
、colors
等等。domain
:实体类(entity
)组件包,还有一些接口类,如repository
、usercase
等。data
:数据提供组件包,主要有:api_request
,database
、shared_preference
等,该组件包所有的调用实现都在domain
中接口repository
的实现类repository_impl
中。shared
:工具类组件包,包括:util
、helper
、enum
、constants
、exception
、mixins
等等。resources
:资源类组件包,有intl
、公共的images
等initializer
:模块初始化组件包。widgets
:公共的UI
组件包,如常用的:alert
、button
、toast
、slider
等等。
它们之间的调用关系如下图:
其中 shared
和 resources
作为基础组件包,本身不依赖任何组件,而是给其它组件包提供支持。
作为主工程 App
也不会直接依赖 data
组件包,其调用是通过 domain
组件包中 UseCase
来实现,在 UseCase
会获取数据、处理列表数据的分页、参数校验、异常处理等等,获取数据是通过调用抽象类 repository
中相关函数,而不是直接调用具体实现类,此时App
的 pubspec.yaml
中配置是这样的:
name: app
description: A new Flutter project.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
environment:
sdk: ">=2.17.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
widgets:
path: ../widgets
shared:
path: ../shared
domain:
path: ../domain
resources:
path: ../resources
initializer:
path: ../initializer
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
generate: false
assets:
- assets/images/
提供的数据组件包 data
实现了抽象类 repository
中相关函数,只负责调用 Api 接口获取数据,或者从数据库获取数据。当上层调用的时候不需要关心数据是从哪里来的,全部交给 data
组件包负责。
initializer
作为模块初始化组件包,仅有一个 AppInitializer
类,其主要目的是将其它的模块的初始化收集起来放在 AppInitializer
类中 init()
函数中,然后在主工程入口函数:main()
调用这个 init()
函数,常见的初始化如:GetIt
初始化、数据库 objectbox
初始化、SharedPreferences
初始化,这些相关的初始会分布在各自的组件包中。
class AppInitializer {
AppInitializer();
Future<void> init() async {
await SharedConfig.getInstance().init();
await DataConfig.getInstance().init();
await DomainConfig.getInstance().init();
}
}
widgets
作为公共的 UI 组件库,不处理业务逻辑,在多项目开发时经常会使用到。上图中的 Other Plugin Module
指的的是其它组件包,特别是需要单独开发与原生交互的插件时会用到,
这种分层设计出来的架构或许在开发过程中带来一下不便,如调用一个接口,第一步:需要先在抽象类 repository
写好函数声明;第二步:然后再去Api Service
写具体请求代码,并在repository_impl
实现类中调用;第三步:还需要在 UserCase
去做业务调用,错误处理等;最后一步:在bloc
的event
中调用。这么一趟下来,确实有些繁琐或者说是过度设计。但是如果维度设定在大的项目中多人合作开发的时候,却能规避很多问题,每个分层组件都有自己的职责互不干扰,都支持单独的开发测试,尽可能的做到依赖于抽象而不是具体的实现。
本篇文章就到这里,源码后面这个系列的文章里放出来,感谢您的阅读,也希望您能关注我的公众号 Flutter技术实践,原创不易,您的关注是我更新下去最大的动力。
来源:juejin.cn/post/7350876924393422886
当 App 有了系统权限,真的可以为所欲为?
前一段时间有个 App 很火,是 Android App 利用了 Android 系统漏洞,获得了系统权限,做了很多事情。想看看这些个 App 在利用系统漏洞获取系统权限之后,都干了什么事,于是就有了这篇文章。由于准备仓促,有些 Code 没有仔细看,感兴趣的同学可以自己去研究研究,多多讨论,对应的文章和 Code 链接都在下面:
关于这个 App 是如何获取这个系统权限的,Android 反序列化漏洞攻防史话,这篇文章讲的很清楚,就不再赘述了,我也不是安全方面的专家,但是建议大家多读几遍这篇文章
序列化和反序列化是指将内存数据结构转换为字节流,通过网络传输或者保存到磁盘,然后再将字节流恢复为内存对象的过程。在 Web 安全领域,出现过很多反序列化漏洞,比如 PHP 反序列化、Java 反序列化等。由于在反序列化的过程中触发了非预期的程序逻辑,从而被攻击者用精心构造的字节流触发并利用漏洞从而最终实现任意代码执行等目的。
这篇文章主要来看看 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个库里面提供的 Dex ,看看 App 到底想知道用户的什么信息?总的来说,App 获取系统权限之后,主要做了下面几件事(正常 App 无法或者很难做到的事情),各种不把用户当人了。
- 自启动、关联启动相关的修改,偷偷打开或者默认打开:与手机厂商斗智斗勇。
- 开启通知权限。
- 监听通知内容。
- 获取用户的使用手机的信息,包括安装的 App、使用时长、用户 ID、用户名等。
- 修改系统设置。
- 整一些系统权限的工具方便自己使用。
另外也可以看到,这个 App 对于各个手机厂商的研究还是比较深入的,针对华为、Oppo、Vivo、Xiaomi 等终端厂商都有专门的处理,这个也是值得手机厂商去反向研究和防御的。
最好我还加上了这篇文章在微信公众号发出去之后的用户评论,以及知乎回答的评论区(问题已经被删了,但是我可以看到:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58… 2471 个赞)可以说是脑洞大开(关于 App 如何作恶)。
0. Dex 文件信息
本文所研究的 dex 文件是从 XXX apk 内嵌提权代码,及动态下发 dex 分析 这个仓库获取的,Dex 文件总共有 37 个,不多,也不大,慢慢看。这些文件会通过后台服务器动态下发,然后在 App 启动的时候进行动态加载,可以说是隐蔽的很,然而 Android 毕竟是开源软件,要抓你个 App 的行为还是很简单的,这些 Dex 就是被抓包抓出来的,可以说是人脏货俱全了。
由于是 dex 文件,所以直接使用 github.com/tp7309/TTDe… 这个库的反编译工具打开看即可,比如我配置好之后,直接使用 showjar 这个命令就可以
showjar 95cd95ab4d694ad8bdf49f07e3599fb3.dex
默认是用 jadx 打开,就可以看到反编译之后的内容,我们重点看 Executor 里面的代码逻辑即可
打开后可以看到具体的功能逻辑,可以看到一个 dex 一般只干一件事,那我们重点看这件事的核心实现部分即可
1. 通知监听和通知权限相关
1.1 获取 Xiaomi 手机通知内容
- 文件 : 95cd95ab4d694ad8bdf49f07e3599fb3.dex
- 功能 :获取用户的 Active 通知
- 类名 :com.google.android.sd.biz_dynamic_dex.xm_ntf_info.XMGetNtfInfoExecutor
1. 反射拿到 ServiceManager
一般我们会通过 ServiceManager 的 getService 方法获取系统的 Service,然后进行远程调用
2. 通过 NotificationManagerService 获取通知的详细内容
通过 getService 传入 NotificationManagerService 获取 NotificationManager 之后,就可以调用 getActiveNotifications 这个方法了,然后具体拿到 Notification 的下面几个字段
- 通知的 Title
- 发生通知的 App 的包名
- 通知发送时间
- key
- channelID :the id of the channel this notification posts to.
可能有人不知道这玩意是啥,下面这个图里面就是一个典型的通知
可以看到 getActiveNotifications 这个方法,是 System-only 的,普通的 App 是不能随便读取 Notification 的,但是这个 App 由于有权限,就可以获取
当然微信的防撤回插件使用的一般是另外一种方法,比如辅助服务,这玩意是合规的,但是还是推荐大家能不用就不用,它能帮你防撤回,他就能获取通知的内容,包括你知道的和不知道的
1.2. 打开 Xiaomi 手机上的通知权限(Push)
- 文件 :0fc0e98ac2e54bc29401efaddfc8ad7f.dex
- 功能 :可能有的时候小米用户会把 App 的通知给关掉,App 想知道这个用户是不是把通知关了,如果关了就偷偷打开
- 类名 :com.google.android.sd.biz_dynamic_dex.xm_permission.XMPermissionExecutor
这么看来这个应该还是蛮实用的,你个调皮的用户,我发通知都是为了你好,你怎么忍心把我关掉呢?让我帮你偷偷打开吧
App 调用 NotificationManagerService 的 setNotificationsEnabledForPackage 来设置通知,可以强制打开通知
frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java
然后查看 NotificationManagerService 的 setNotificationsEnabledForPackage 这个方法,就是查看用户是不是打开成功了
frameworks/base/services/core/java/com/android/server/notification/NotificationManagerService.java
还有针对 leb 的单独处理~细 !
1.3. 打开 Vivo 机器上的通知权限(Push)
- 文件 :2eb20dc580aaa5186ee4a4ceb2374669.dex
- 功能 :Vivo 用户会把 App 的通知给关掉,这样在 Vivo 手机上 App 就收不到通知了,那不行,得偷偷打开
- 类名 :com.google.android.sd.biz_dynamic_dex.vivo_open_push.VivoOpenPushExecutor
核心和上面那个是一样的,只不过这个是专门针对 vivo 手机的
1.4 打开 Oppo 手机的通知权限
- 文件 :67c9e686004f45158e94002e8e781192.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.oppo_notification_ut.OppoNotificationUTExecutor
没有反编译出来,看大概的逻辑应该是打开 App 在 oppo 手机上的通知权限
1.5 Notification 监听
- 文件 :ab8ed4c3482c42a1b8baef558ee79deb.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.ud_notification_listener.UdNotificationListenerExecutor
这个就有点厉害了,在监听 App 的 Notification 的发送,然后进行统计
这个咱也不是很懂,是时候跟做了多年 SystemUI 和 Launcher 的老婆求助了....@史工
1.6 App Notification 监听
- 文件 :4f260398-e9d1-4390-bbb9-eeb49c07bf3c.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.notification_listener.NotificationListenerExecutor
上面那个是 UdNotificationListenerExecutor , 这个是 NotificationListenerExecutor,UD 是啥?
这个反射调用的 setNotificationListenerAccessGranted 是个 SystemAPI,获得通知的使用权,果然有权限就可以为所欲为
1.7 打开华为手机的通知监听权限
- 文件 :a3937709-b9cc-48fd-8918-163c9cb7c2df.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_notification_listener.HWNotificationListenerExecutor
1.8 打开华为手机通知权限
- 文件 :257682c986ab449ab9e7c8ae7682fa61.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_permission.HwPermissionExecutor
2. Backup 状态
2.1. 鸿蒙 OS 上 App Backup 状态相关,保活用?
- 文件 :6932a923-9f13-4624-bfea-1249ddfd5505.dex
- 功能 :Backup 相关
这个看了半天,应该是专门针对华为手机的,收到 IBackupSessionCallback 回调后,执行 PackageManagerEx.startBackupSession 方法
查了下这个方法的作用,启动备份或恢复会话
2.2. Vivo 手机 Backup 状态相关
- 文件 :8c34f5dc-f04c-40ba-98d4-7aa7c364b65c.dex
- 功能 :Backup 相关
3. 文件相关
3.1 获取华为手机 SLog 和 SharedPreferences 内容
- 文件 : da03be2689cc463f901806b5b417c9f5.dex
- 类名 :com.google.android.sd.biz_dynamic_dex.hw_get_input.HwGetInputExecutor
拿这个干嘛呢?拿去做数据分析?
获取 SharedPreferences
获取 slog
4. 用户数据
4.1 获取用户使用手机的数据
- 文件 : 35604479f8854b5d90bc800e912034fc.dex
- 功能 :看名字就知道是获取用户的使用手机的数据
- 类名 :com.google.android.sd.biz_dynamic_dex.usage_event_all.UsageEventAllExecutor
看核心逻辑是同 usagestates 服务,来获取用户使用手机的数据,难怪我手机安装了什么 App、用了多久这些,其他 App 了如指掌
那么他可以拿到哪些数据呢?应有尽有~,包括但不限于 App 启动、退出、挂起、Service 变化、Configuration 变化、亮灭屏、开关机等,感兴趣的可以看一下:
frameworks/base/core/java/android/app/usage/UsageEvents.java
private static String eventToString(int eventType) {
switch (eventType) {
case Event.NONE:
return "NONE";
case Event.ACTIVITY_PAUSED:
return "ACTIVITY_PAUSED";
case Event.ACTIVITY_RESUMED:
return "ACTIVITY_RESUMED";
case Event.FOREGROUND_SERVICE_START:
return "FOREGROUND_SERVICE_START";
case Event.FOREGROUND_SERVICE_STOP:
return "FOREGROUND_SERVICE_STOP";
case Event.ACTIVITY_STOPPED:
return "ACTIVITY_STOPPED";
case Event.END_OF_DAY:
return "END_OF_DAY";
case Event.ROLLOVER_FOREGROUND_SERVICE:
return "ROLLOVER_FOREGROUND_SERVICE";
case Event.CONTINUE_PREVIOUS_DAY:
return "CONTINUE_PREVIOUS_DAY";
case Event.CONTINUING_FOREGROUND_SERVICE:
return "CONTINUING_FOREGROUND_SERVICE";
case Event.CONFIGURATION_CHANGE:
return "CONFIGURATION_CHANGE";
case Event.SYSTEM_INTERACTION:
return "SYSTEM_INTERACTION";
case Event.USER_INTERACTION:
return "USER_INTERACTION";
case Event.SHORTCUT_INVOCATION:
return "SHORTCUT_INVOCATION";
case Event.CHOOSER_ACTION:
return "CHOOSER_ACTION";
case Event.NOTIFICATION_SEEN:
return "NOTIFICATION_SEEN";
case Event.STANDBY_BUCKET_CHANGED:
return "STANDBY_BUCKET_CHANGED";
case Event.NOTIFICATION_INTERRUPTION:
return "NOTIFICATION_INTERRUPTION";
case Event.SLICE_PINNED:
return "SLICE_PINNED";
case Event.SLICE_PINNED_PRIV:
return "SLICE_PINNED_PRIV";
case Event.SCREEN_INTERACTIVE:
return "SCREEN_INTERACTIVE";
case Event.SCREEN_NON_INTERACTIVE:
return "SCREEN_NON_INTERACTIVE";
case Event.KEYGUARD_SHOWN:
return "KEYGUARD_SHOWN";
case Event.KEYGUARD_HIDDEN:
return "KEYGUARD_HIDDEN";
case Event.DEVICE_SHUTDOWN:
return "DEVICE_SHUTDOWN";
case Event.DEVICE_STARTUP:
return "DEVICE_STARTUP";
case Event.USER_UNLOCKED:
return "USER_UNLOCKED";
case Event.USER_STOPPED:
return "USER_STOPPED";
case Event.LOCUS_ID_SET:
return "LOCUS_ID_SET";
case Event.APP_COMPONENT_USED:
return "APP_COMPONENT_USED";
default:
return "UNKNOWN_TYPE_" + eventType;
}
}
4.2 获取用户使用数据
- 文件:b50477f70bd14479a50e6fa34e18b2a0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.usage_event.UsageEventExecutor
上面那个是 UsageEventAllExecutor,这个是 UsageEventExecutor,主要拿用户使用 App 相关的数据,比如什么时候打开某个 App、什么时候关闭某个 App,6 得很,真毒瘤
4.3 获取用户使用数据
- 文件:1a68d982e02fc22b464693a06f528fac.dex
- 类名:com.google.android.sd.biz_dynamic_dex.app_usage_observer.AppUsageObserver
看样子是注册了 App Usage 的权限,具体 Code 没有出来,不好分析
5. Widget 和 icon 相关
经吃瓜群众提醒,App 可以通过 Widget 伪造一个 icon,用户在长按图标卸载这个 App 的时候,你以为卸载了,其实是把他伪造的这个 Widget 给删除了,真正的 App 还在 (不过我没有遇到过,这么搞真的是脑洞大开,且不把 Android 用户当人)
5.1. Vivo 手机添加 Widget
- 文件:f9b6b139-4516-4ac2-896d-8bc3eb1f2d03.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_widget.VivoAddWidgetExecutor
这个比较好理解,在 Vivo 手机上加个 Widget
5.2 获取 icon 相关的信息
- 文件:da60112a4b2848adba2ac11f412cccc7.dex
- 类名:com.google.android.sd.biz_dynamic_dex.get_icon_info.GetIconInfoExecutor
这个好理解,获取 icon 相关的信息,比如在 Launcher 的哪一行,哪一列,是否在文件夹里面。问题是获取这玩意干嘛???迷
5.3 Oppo 手机添加 Widget
- 文件:75dcc8ea-d0f9-4222-b8dd-2a83444f9cd6.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppoaddwidget.OppoAddWidgetExecutor
5.4 Xiaomi 手机更新图标?
- 文件:5d372522-b6a4-4c1b-a0b4-8114d342e6c0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.xm_akasha.XmAkashaExecutor
小米手机上的桌面 icon 、shorcut 相关的操作,小米的同学来认领
6. 自启动、关联启动、保活相关
6.1 打开 Oppo 手机自启动
- 文件:e723d560-c2ee-461e-b2a1-96f85b614f2b.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_boot_perm.OppoBootPermExecutor
看下面这一堆就知道是和自启动相关的,看来自启动权限是每个 App 都蛋疼的东西啊
6.2 打开 Vivo 关联启动权限
- 文件:8b56d820-cac2-4ca0-8a3a-1083c5cca7ae.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_association_start.VivoAssociationStartExecutor
直接写了个节点进去
6.3 关闭华为耗电精灵
- 文件:7c6e6702-e461-4315-8631-eee246aeba95.dex
- 类名:com.google.android.sd.biz_dynamic_dex.hw_hide_power_window.HidePowerWindowExecutor
看名字和实现,应该是和华为的耗电精灵有关系,华为的同学可以来看看
6.4 Vivo 机型保活相关
- 文件:7877ec6850344e7aad5fdd57f6abf238.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_get_loc.VivoGetLocExecutor
猜测和保活相关,Vivo 的同学可以来认领一下
7. 安装卸载相关
7.1 Vivo 手机回滚卸载
- 文件:d643e0f9a68342bc8403a69e7ee877a7.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_rollback_uninstall.VivoRollbackUninstallExecutor
这个看上去像是用户卸载 App 之后,回滚到预置的版本,好吧,这个是常规操作
7.2 Vivo 手机 App 卸载
- 文件:be7a2b643d7e8543f49994ffeb0ee0b6.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.OfficialUntiUninstallV3
7.3 Vivo 手机 App 卸载相关
- 文件:183bb87aa7d744a195741ce524577dd0.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_official_uninstall.VivoOfficialUninstallExecutor
其他
SyncExecutor
- 文件:f4247da0-6274-44eb-859a-b4c35ec0dd71.dex
- 类名:com.google.android.sd.biz_dynamic_dex.sync.SyncExecutor
没看懂是干嘛的,核心应该是 Utils.updateSid ,但是没看到实现的地方
UdParseNotifyMessageExecutor
- 文件:f35735a5cbf445c785237797138d246a.dex
- 类名:com.google.android.sd.biz_dynamic_dex.ud_parse_nmessage.UdParseNotifyMessageExecutor
看名字应该是解析从远端传来的 Notify Message,具体功能未知
TDLogcatExecutor
- 文件
- 8aeb045fad9343acbbd1a26998b6485a.dex
- 2aa151e2cfa04acb8fb96e523807ca6b.dex
- 类名
- com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor
- com.google.android.sd.biz_dynamic_dex.td.logcat.TDLogcatExecutor
没太看懂这个是干嘛的,像是保活又不像,后面有时间了再慢慢分析
QueryLBSInfoExecutor
- 文件:74168acd-14b4-4ff8-842e-f92b794d7abf.dex
- 类名:com.google.android.sd.biz_dynamic_dex.query_lbs_info.QueryLBSInfoExecutor
获取 LBS Info
WriteSettingsExecutor
- 文件:6afc90e406bf46e4a29956aabcdfe004.dex
- 类名:com.google.android.sd.biz_dynamic_dex.write_settings.WriteSettingsExecutor
看名字应该是个工具类,写 Settings 字段的,至于些什么应该是动态下发的
OppoSettingExecutor
- 文件:61517b68-7c09-4021-9aaa-cdebeb9549f2.dex
- 类名:com.google.android.sd.biz_dynamic_dex.opposettingproxy.OppoSettingExecutor
Setting 代理??没看懂干嘛的,Oppo 的同学来认领,难道是另外一种形式的保活?
CheckAsterExecutor
- 文件:561341f5f7976e13efce7491887f1306.dex
- 类名:com.google.android.sd.biz_dynamic_dex.check_aster.CheckAsterExecutor
Check aster ?不是很懂
OppoCommunityIdExecutor
- 文件:538278f3-9f68-4fce-be10-12635b9640b2.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_community_id.OppoCommunityIdExecutor
获取 Oppo 用户的 ID?要这玩意干么?
GetSettingsUsernameExecutor
- 文件:4569a29c-b5a8-4dcf-a3a6-0a2f0bfdd493.dex
- 类名:com.google.android.sd.biz_dynamic_dex.oppo_get_settings_username.GetSettingsUsernameExecutor
获取 Oppo 手机用户的 username,话说你要这个啥用咧?
LogcatExecutor
- 文件:218a37ea-710d-49cb-b872-2a47a1115c69.dex
- 类名:com.google.android.sd.biz_dynamic_dex.logcat.LogcatExecutor
VivoBrowserSettingsExecutor
- 文件:136d4651-df47-41b4-bb80-2ec0ab1bc775.dex
- 类名:com.google.android.sd.biz_dynamic_dex.vivo_browser_settings.VivoBrowserSettingsExecutor
Vivo 浏览器相关的设置,不太懂要干嘛
评论区比文章更精彩
微信公众号评论区
知乎评论区
知乎回答已经被删了,我通过主页可以看到,但是点进去是已经被删了:如何评价拼多多疑似利用漏洞攻击用户手机,窃取竞争对手软件数据,防止自己被卸载? - Gracker 的回答 - 知乎 http://www.zhihu.com/question/58…
iOS 和 Android 哪个更安全?
这里就贴一下安全大佬 sunwear 的评论
关于我 && 博客
- 关于我 , 非常希望和大家一起交流 , 共同进步 .
- 博客内容导航
- 优秀博客文章记录 - Android 性能优化必知必会
一个人可以走的更快 , 一群人可以走的更远
来源:juejin.cn/post/7310474225809784884
错怪react的半年-聊聊keepalive
背景
在半年前的某一天,一个运行一年的项目被人提了建议,希望有一个tabs页面,因为他需要多个页面找东西。
我:滚,自己开浏览器标签页(此处吹牛逼)
项目经理:我审核的时候也需要看别人提交的数据是否正确,我也需要看,很多人提了建议
我:作为一个合格的外包,肯定以项目经理体验为主(狗头保命)
1 React的Keepalive
简单实现React KeepAlive不依赖第三方库(附源码)React KeepAlive 实现,不依赖第三方库,支持 - 掘金
神说要有光《React通关秘籍》
....还有一些没有收藏的keepalive实现
代码不想多赘述,我找了很多资料基本思路讲一下
基本是通过react-router来实现
- 弄一个map来存储{path:<OutLet|useOutlet>}
- 然后根据当前路由path来决定哪些组件需要渲染,不渲染的hidden
- 然后在最外层布局套一层Tabs布局用react的context来传递
出现的问题是:
就是当依赖项是一个公共数据的时候,useEffect会触发,如图片中的searchParams同名的key、存在state内存中的同名key之类的
详情页打开多个,地址栏清一色pkId的情况
使用的是ant design pro v6,也是看到有加配置就可以用Keepalive,大家可以掘金找找,我测试过也是有一样的问题,但是和目前能找到功能差不多了,免去自己封装
看很多react Keepalive的实现目前是还没找到什么方案,也皮厚的找过神光,但是要是知道我是被人忽悠了,绝对不会去打扰大佬
tip:先自己敲,再问,不要让自己陷于蠢逼的尴尬
发现问题后,我去问日常开发Vue的童鞋们,因为我两年没写vue了,我说:你们是怎么实现tabs布局的,Vue的Keepalive是怎么实现只有显示哪个页面,别的组件存储而不运行watch的,然后说了一下我在react开发tabs的时候尴尬。
Vueer:vue不会有这个问题,自带的Keepalive封装的很完美,react没有这个功能吗?
就这样我信了半年,但是这个bug我说,我只能到这种程度了,要是说新项目还可以再useEffect再封装一层自定义的hooks,增加一点心智负担,让别人用封装的来,再useEffect内做判断是否是显示的path之类的。
这边年来反正这个功能做了一个开关,项目经理自己用,别人要是没问也不告诉别人有开发这东西,嘿嘿嘿
之所以又捞起来是别的几个项目也有人问,然后问我能不能迁移给别的项目,那他妈不得王炸,别的项目有的还是umi3没升级的。
2 vue3的Keepalive
先说结论吧:vue的Keepalive也能解决这个问题,纯属胡扯
实在想不到什么好的方案,闲余时间,就用vue写了一个demo,想看看vue是怎么实现的,因为react除了就是hidden,或者超出overflow 然后切换是平移像轮播图一样,实在想不出什么方案能保存原本的数据了。
<template>
home
<ul>
<li v-for="item in router.getRoutes().filter(r=>r.path!=='/')" @click="liHandler(item)">
{{ item.path }}
</li>
</ul>
</template>
<script lang="ts" setup>
import {useRouter} from "vue-router";
const router = useRouter()
const liHandler = (route) => {
router.push({name: route.name, query: {path: route.path}})
}
</script>
简单来个demo的目录结构和代码,代码是会有问题的代码,不用细看...
两年没写vue还是遇到几个坑,先记录一下
2.1 vue3的routerview不能写在keepAlive内
// home.vue
<template>
<!-- <RouterView v-slot="{Component}">-->
<!-- <KeepAlive>-->
<!-- <component :is="Component"/>-->
<!-- </KeepAlive>-->
<!-- </RouterView>-->
<KeepAlive>
<RouterView></RouterView>
</KeepAlive>
</template>
注释掉的是正确的,这里不提一嘴,vue的console.log的体验感很ok,下面列的就是正确的,我还去百度了为啥Keepalive无效,直到截这张图写这文的时候才看到,人家都谢了,哈哈哈哈
2.2 router.push地址不变
主要原因是路由创建的是
createMemoryHistory 这玩意不知道是啥 没用过,我是复制vueRoute官网demo的,一开始没注意,改成createWebHistory就好
import {createRouter, createWebHistory} from "vue-router";
const routes = [
{path: "/", component: () => import("./components/Home.vue")},
{path: "/aaa", component: () => import("./components/Aaa.vue"), name: 'aaa'},
{path: "/bbb", component: () => import("./components/Bbb.vue"), name: 'bbb'},
{path: "/ccc", component: () => import("./components/Ccc.vue"), name: 'ccc'},
{path: "/ddd", component: () => import("./components/Ddd.vue"), name: 'ddd'},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
2.3 router.push不拼接?传参
router.push(path, query: {path}})
一开始是这么写的,用path+query,这个问题纯粹弱智了,太久没写有点菜,改成name+query就行
2.4 vue Keepalive测试
我先点了去aaa,然后返回首页点ccc,这里可以看到,aaa页面的watch触发了!触发了!触发了!
然后去看了下源码,简约版如下
const KeepAliveImpl = {
name: `KeepAlive`,
// 私有属性 标记 该组件是一个KeepAlive组件
__isKeepAlive: true,
props: {
// 用于匹配需要缓存的组件
include: [String, RegExp, Array],
// 用于匹配不需要缓存的组件
exclude: [String, RegExp, Array],
// 用于设置缓存上线
max: [String, Number]
},
setup(props, { slots }) {
// 省略部分代码...
// 返回一个函数
return () => {
if (!slots.default) {
return null
}
// 省略部分代码...
// 获取子节点
const children = slots.default()
// 获取第一个子节点
const rawVNode = children[0]
// 返回原始Vnode
return rawVNode
}
}
}
这不就是存下来了children,但是有个有意思的是
前面说过,react是通过缓存住组件,然后用hidden来控制展示哪个隐藏哪个,vue这边的dom渲染出来不是。
官网也说了动态组件是会卸载的
3 分析一波
直接用光哥的代码跑起来,这么一比可以看出来dom上的差异
然后把代码的显示改成判断语句渲染,测试一下
测试图:
然后去首页,再回到/bbb,发现数字又变回0了
先来看下React的渲染,通过卡颂大佬的文章,找到了react在render阶段
这是判断是mountd还是update的一个标志,然而我们已经卸载了,这里肯定是null,那么就会去创建组建,仅仅是创建。
而vue因为自身有keepalive,在render阶段,是有对标志为keepalive的做patch的逻辑
所以keepalive的组件不会再走created和mounted,而是直接进行diff进行parch
总结
- 公共参数无论是vue还是react都会被监听到
- react想要实现像vue一样的效果只能等react官方适配
也是有提过啦
来源:juejin.cn/post/7436955628263784475
谈谈HTML5a标签的ping属性用法
前言
今天谈谈a标签ping属性的用法,这个用法可以用来做埋点,及用户上报,关于埋点,我之前有文章写过,利用空白gif图片,实现数据上报,ping的这种方式可以发送post请求给后端,当然也可以通过这个做DDOS攻击,今天详细介绍一下。
Ping的用法
Ping的用法相对比较简单,我们通过举例的方式,为大家介绍:
href="https://www.haorooms.com/" ping="https://www.haorooms.com /nav">点击haorooms博客
当你点击‘点击haorooms博客’的时候,会异步发送一个POST请求到Ping后面指定的地址,Request Body的内容为PING。或许你会问,那
ping="https://www.haorooms.com /nav">点击haorooms博客
这段代码行不行?答案是否定的,和HTML中的a标签一样,HTML5中href这个属性必须存在与a中,不然Ping也是不会运行的。
应用一,埋点上报
我们可以看到 ping 请求的 content-type 是 text/ping,包含了用户的 User-Agent,是否跨域,目标来源地址等信息,非常方便数据收集的时候进行追踪。可以利用这个进行埋点上报,点击上报等。
Ping可以进行广告追踪,它可以统计用户都点击了哪些链接以及次数,并使用POST请求把这些信息发送到广告商的服务器上。那么POST的这些信息都包含了什么呢,简单来说HTTP Header的内容都会有,我们来看一个截获的完整信息
HOST: haorooms.com
CONTENT-LENGTH: 4
ORIGIN: http://mail.163.com
PING-FROM: http://****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977
USER-AGENT: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36
PING-TO: http://www.baidu.com/
CONTENT-TYPE: text/ping
ACCEPT: */*
REFERER: http://****.com/js6/read/readhtml.jsp?mid=458:xtbBygBMgFO+dvBcvQAAsM&font=15&color=064977
ACCEPT-ENCODING: gzip, deflate
ACCEPT-LANGUAGE: zh-CN,zh;q=0.8
COOKIE: sessionid=rnbymrrkbkipn7byvdc2hsem5o0vrr13
CACHE-CONTROL: max-age=0
CONNECTION: keep-alive
PING-FROM、USER-AGENT、REFERER这三个关键信息,直接泄漏了用户的隐私(但几个月前,百度已宣布不支持REFERER)。而这也为我们最爱的XSSSHELL又提供了一个小插件。对于图片探针如果没了新鲜感,那么请试试Ping探针吧,简单的一句
href="" ping=>
就搞定!
ping 属性的优势
1、无需 JavaScript 代码参与,网页功能异常也能上报;
2、不受浏览器刷新、跳转过关闭影响,也不会阻塞页面后续行为,这一点和 navigator.sendBeacon()
类似,可以保证数据上报的准确性; 支持跨域;
href="https://www.haorooms.com/" ping="https://www.baidu.com/ad.php">点击我
3、可上报大量数据,因为是 POST 请求;
4、语义明确,使用方便,灵活自主。
ping 属性的劣势
1、只能支持点击行为的上报,如果是进入视区,或弹框显示的上报,需要额外触发下元素的 click() 行为;
2、只能支持 a 元素,在其他元素上设置 ping 属性没有作用,这就限制了其使用范围,因为很多开发喜欢 div 一把梭。
3、只能是 POST 请求,目前主流的数据统计还是日志中的 GET 请求,不能复用现有的基建。
4、出生不好,身为 HTML 属性,天然受某些开发者无视与不屑。
5、适合在移动端项目使用,PC端需要酌情使用(不需要考虑上报总量的情况下),因为目前 IE 和 Firefox
浏览器都不支持(或没有默认开启支持)。
应用二,DDOS攻击
根据Ping发送POST请求这个特性,我们可以使用循环使之不停的向一个地址追加POST请求,造成DOS攻击。
var arr = ['https://www.haorooms1.com', 'https://www.haorooms2.com', 'https://www.haorooms3.com'];
function haoroomsDOS( ){
var indexarr = Math.floor((Math.random( )*arr.length));
document.writeln("");
}
if(arr.length>0){
var htimename = setlnterval("haoroomsDOS()", 1000);
}
防御方法
web服务器可以通过WAF(如:ShareWAF,http://www.sharewaf.com/)等拦截含有“Ping… HTTP headers的请求。
来源:juejin.cn/post/7438964981453094966
HTML 还有啥可学的?这份年终总结帮你梳理
💰 点进来就是赚到知识点!本文带你解读 2024年 HTML 的发展现状,点赞、收藏、评论更能促进消化吸收!
前言
作为前端三驾马车之一的 HTML,其关注度可能不如 CSS 和 JavaScript 那样高。但这绝不是因为它不重要,正相反,作为 Web 生态的基石,HTML 是最早被设计出来构成 Web 页面的基本标准,它简明、稳定,所以非常让开发者省心,绝不是 CSS 和 JavaScript 那种闹人的孩子。
一般来说,30 岁的人就不怎么长高了,那么这项 30 岁的 Web 技术,是否也已经悄悄停止了生长呢?我的答案是:并没有。这不,最近《2024 HTML 年度调查结果报告》新鲜出炉,从从业人群、特性、工具等维度统计了来自全球 5000+ 的问卷结果,汇总出了 2024 年 HTML 的完整面貌。
如果你也不甘落后,想与业界保持同步的技术认知和水平,但又没时间仔细研究完整个维度繁复、类目庞杂的调查报告,那接下来,我会带你直击几个核心类目的 Top 5,让你轻松了解全球开发者最爱用、最关注、最期待的特性和 API。
最常用功能 Top5
上图中列出的功能,是用过人数最多的前 5 个元素。
Landmark
元素:<main>
、<nav>
、<aside>
、<header>
、<footer>
、<section>
这些 HTML5 语义化标签。还记得十年前我入行前端时,「什么是 HTML 语义化」是必考的面试题。tabindex
:控制元素的聚焦交互,是提升用户操作效率的小妙招。- 懒加载:控制图片、视频或 iframe 的加载时机,可以有效节省带宽、提升首屏加载速度。
srcset
:设置多媒体元素的源路径,它的广泛使用代表着 Web 页面内容的多样性。<details>
和<summary>
:原生折叠/展开控件。我得承认我还没直接使用过,看来技术栈要更新了。
最想了解的特性 Top5
如上图所示,这 5 个特性是开发者们在填完问卷后最想要第一时间去学习的。
- 自定义
Select
元素:可自定义内容和样式的下拉菜单,目前包括<selectlist>
和<selectmenu>
。
focusgroup
:让用户能用键盘的方向键来选中聚焦元素,提升操作体验和效率。- Popover API:原生的弹层组件
- EditContext API:控制元素可编辑性
- 自定义高亮:用 CSS 控制文本选中后的样式
Web 组件库 Top5
当被问到用哪些库/框架来搭建 UI 界面时,上图中这 5 种库名列前茅;而大家熟知的 Vue、React 则分别排在了第 12 和第 13。是不是很意外?其实这可能和问题的语境有关系。大型 Web 应用为了方便协作和维护一般用主流框架,但也有些中小工程用一些简洁框架反而更高效。
用 Web 技术开发原生应用时最常用的特性 Top5
这一领域相对小众,样本数量下降了一个量级。但也为我们提供了不一样的视角,看到一些新鲜的 API:
- Web Share API:用于控制分享逻辑。
- File System Access API:用于处理设备本地的文件,增删改查样样行,能力超强,我有一个专栏就是写这个 API 的。
- Launch API:控制 PWA 的启动逻辑。
- FIle Handling API:用于在 PWA 中注册文件类型。
- WIndow Controls Overlay API:PWA 控制自定义内容的显示。
网站生成框架 Top5
这类框架一般用于静态官网、博客等站点的生成。
- Next.js:基于 React,无论是国内外都是应用最广的主流框架。
- Astro:它有一套自己的组件体系,像 Vue 但又有独到之处,很适合搭建博客。
- Nuxt:基于 Vue,对标 Next.js。我在用,一套代码搞定前后端逻辑,非常爽。
- SvelteKit:顾名思义是 Svelte 的配套生态。
- Eleventy:还没用到过,从官网介绍看,是主打小巧简洁。很想玩一玩。
信息来源 Top5
这一类目统计了开发者们日常获取泛 HTML 知识和信息的渠道,从数据可以看到大家主要用的都是上图这几种。
呼声最高的补完计划 Top5
有这么一些组件,是咱们日常开发非常常用,但 HTML 却迟迟没有提供原生支持的:
- 数据表格:指的是自带排序、过滤等常用功能的 table。
- 标签页组件
- Switch/Toggle 开关
- 骨架屏、Loading 组件
- 右键菜单
结语
恭喜你读完本文,你真棒!
这一次我们选取了 7 个核心维度来解读 《2024 HTML 年度调查结果报告》。如果其中有你陌生的技术点,那正好可以查缺补漏。
最后,咱们玩个互动小游戏:
把你的输入法切到中文,再按 H
、T
、M
、L
这四个键,把你最离谱的联想词打在评论区,看看谁最逆天!
我用的是小鹤双拼,所以打出了「混天绫」,笑死,每天都用 HTML,原来我是哪吒。
📣 我是 Jax,在畅游 Web 技术海洋的又一年,我仍然是坚定不移的 JavaScript 迷弟,Web 技术带给我太多乐趣。如果你也和我一样,欢迎关注、私聊!
来源:juejin.cn/post/7439353204054228992
离谱,split方法的设计缺陷居然导致了生产bug!
需求简介
大家好,我是石小石!前几天实现了这样一个需求:
根据后端images字段返回的图片字符,提取图片key查找图片链接并渲染。
由于后端返回的是用逗号分隔的字符,所以获取图片的key使用split方法非常方便。
if(data.images != null || data.images != undefined){
// 将字符通过split方法分割成数组
const picKeyList = data.images.split(",")
picKeyList.forEach(key => {
// 通过图片key查询图片链接
// ...
})
}
乍一看,代码并没有问题,qa同学在测试环境也验证了没有问题!于是,当晚,我们就推送生产了。
生产事故
几天后的一个晚上,我已经睡觉了,突然接到领导的紧急电话,说我开发的页面加载图片后白屏了!来不及穿衣服,我赶紧去排查bug。
通过断点排查,发现当后端返回的
data.images
是空字符“""
”时,用split分割空字符,得到的picKeyList结果是 “[""]
” ,这导致picKeyList遍历时,内部的 key是空,程序执行错误。
然后我用控制台验证了一下split分割空字符,我人傻了。
后来,我也成功的为这次生产事故背锅。我也无可争辩,是我没完全搞懂split方法的作用机制。
ps:宝宝心里苦,为什么后端不直接返回图片的key数组!!为什么!!
split方法
吃一堑,长一智,我决定在复习一下split方法的使用,并梳理它的踩坑点及可能得解决方案。
语法
split()
用于将字符串按照指定分隔符分割成数组
string.split(separator, limit)
separator
(可选):指定分隔符,可以是字符串或正则表达式。如果省略,则返回整个字符串作为数组。limit
(可选):整数,限制返回的数组的最大长度。如果超过限制,多余的部分将被忽略。
基本用法
使用字符串作为分隔符
const text = "苹果,华为,小米";
const result = text.split(",");
console.log(result);
// 输出: ['苹果', '华为', '小米']
使用正则表达式作为分隔符
const text = "苹果,华为,小米";
const result = text.split(/[,; ]+/); // 匹配逗号、分号或空格
console.log(result);
// 输出: ['苹果', '华为', '小米']
使用限制参数
const text = "苹果,华为,小米";
const result = text.split(",", 2);
console.log(result);
// 输出: ['苹果', '华为'] (限制数组长度为 2)
没有找到分隔符
const text = "hello";
const result = text.split(",");
console.log(result);
// 输出: ['hello'] (原字符串直接返回)
split方法常见踩坑点
空字符串的分割
const result = "".split(",");
console.log(result);
// 输出: [''] (非空数组,包含一个空字符串)
原因:
空字符串没有内容,split()
默认返回一个数组,包含原始字符串。
解决方案:
const result = "".split(",").filter(Boolean);
console.log(result);
// 输出: [] (使用 filter 移除空字符串)
多余分隔符
const text = ",,苹果,,华为,,";
const result = text.split(",");
console.log(result);
// 输出: ['', '', '苹果', '', '华为', '', '']
原因:
连续的分隔符会在数组中插入空字符串。
解决方案:
const text = ",,苹果,,华为,,";
const result = text.split(",").filter(Boolean);
console.log(result);
// 输出: ['苹果','华为']
filter(Boolean)
是一个非常常用的技巧,用于过滤掉数组中的假值。
分割 Unicode 字符
const text = "👍😊👨👩👦";
const result = text.split('');
console.log(result);
// 输出: ['👍', '😊', '👨', '', '👩', '', '👦']
原因:
split("")
按字节分割,无法正确识别组合型字符。
解决方案:
const text = "👍😊👨👩👦";
const result = Array.from(text);
console.log(result);
// 输出: ['👍', '😊', '👨👩👦'] (完整分割)
总结
这篇文章通过本人的生产事故,向介绍了split方法使用可能存在的一些容易忽略的bug,希望大家能有所收获。一定要注意split分割空字符会得到一个包含空字符数组的问题!
来源:juejin.cn/post/7439189795614916658
brain.js提升我们前端智能化水平
有时候真的不得不感叹,AI实在是太智能,太强大了。从自动驾驶,家具,AI无处不在。现在我们前端开发领域,AI也成了一种新的趋势,让不少同行压力山大啊。本文我们将探讨AI在前端开发中的应用,以及如何用浏览器端的神经网络库(brain.js)来提升我们前端的智能化水平。
brain.js
开局即重点,我们先来介绍一下bran.js。
brain.js是由Brain团队开发的JavaScript库,专门用于实现神经网络。其源代码可以在Github上,任何人都可以进行查看,提问和贡献代码。
起源
: brain.js 最初是为了让前端开发者能够更容易地接触到机器学习技术而创建的。它的设计目标是提供一个简单易用的接口,同时保持足够的灵活性来满足不同需求。
功能
:
- 实例化神经网络:
<script src="./brain.js"></script>
const net = new brain.recurrent.LSTM();
- 训练模型:可以提供灵活的训练方式,支持多种参数
const data = [
{ input: [0, 0], output: [0] },
{ input: [0, 1], output: [1] },
{ input: [1, 0], output: [1] },
{ input: [1, 1], output: [0] } ];
network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});
- 进行模型推理
const output = network.run([1, 0]); //输出应该接近1
console.log(output);
训练结束后,用run
方法进行推理
实战
话不多说,直接开始实战,这次我们进行一个任务分类,看看是前端还是后端。
- 首先,我们先导包
可以直接利用npm下载,终端输入npm install brain.js
安装,后面代码是这样
const brain = require('brain.js');
require
函数:require
是 Node.js 中用于导入模块的函数。它会在 node_modules
目录中查找指定的模块,并将其导出的对象或函数加载到当前作用域中。
或者你可以像我一样,到Github仓库下好brain.js文件
<script src="./brain.js"></script>
<script>
标签:<script>
标签用于在 HTML 文件中引入外部 JavaScript 文件。src
属性指定了 JavaScript 文件的路径。
完成第一步后,我们要用jason数组给大模型“喂”一些数据,用于后面的推理
const data = [
{ "input": "自定义表单验证 ", "output": "frontend" }, // 前端任务
{ "input": "实现 WebSocket 进行实时通信", "output": "backend" }, // 后端任务
{ "input": "视差滚动效果 ", "output": "frontend" }, // 前端任务
{ "input": "安全存储用户密码", "output": "backend" }, // 后端任务
{ "input": "创建主题切换器(深色/浅色模式) ", "output": "frontend" }, // 前端任务
{ "input": "高流量负载均衡", "output": "backend" }, // 后端任务
{ "input": "为残疾用户提供的无障碍功能": "frontend" }, // 前端任务
{ "input": "可扩展架构以应对增长的用户基础 ", "output": "backend" } // 后端任务 ];
- 再然后,初始化我们的神经网络:
const network = new brain.recurrent.LSTM();
这里的LSTM是brain.js中提供的一种类,用于创建长短期记忆网络Long Short-Term Memory
。
经过这个操作,我们就拥有一个可训练的和可使用的LSTM模型。
- 开始训练
// 训练模型 network.train(data, {
iterations: 2000, // 训练迭代次数
log: true, // 是否打印训练日志
logPeriod: 100 // 日志打印间隔
});
注意
:训练需要花费一段时间。
- 执行程序
const output = network.run("自定义表单验证");// 前端任务
console.log(output);
此时,我们的神经网络就会开始推理这是个什么任务,在进行一段时间的训练后,就会出现结果
此时,正确输出了,这是个前端frontend
任务。
类似的,我们改成
const output = network.run("高流量负载均衡"); // 后端任务
console.log(output);
经过一段时间的训练后,得到
也得到了正确结果,这是个后端backend
任务。
总结
brain.js凭借其简洁的API设计和强大的功能,为前端开发者提供了一个易于上手的工具,降低了进入AI领域的门槛,促进了前端开发与AI技术的深度融合。本文我们用bran.js进行了一个简单的数据投喂,实现了我们的任务。相信在未来会有更具有创新性的应用案例出现,推动行业发展。
来源:juejin.cn/post/7438655509899444251
用了组合式 (Composition) API 后代码变得更乱了,怎么办?
前言
组合式 (Composition) API
的一大特点是“非常灵活”,但也因为非常灵活,每个开发都有自己的想法。加上项目的持续迭代导致我们的代码变得愈发混乱,最终到达无法维护的地步。本文是我这几年使用组合式API的一些经验总结,希望通过本文让你也能够写出易维护、优雅的组合式API
代码。
加入欧阳的高质量vue源码交流群、欧阳平时写文章参考的多本vue源码电子书
选项式API
vue2的选项式API因为每个选项都有固定的书写位置(比如数据就放在data
里面,方法就放在methods
里面),所以我们只需要将代码放到对应的选项中就行了。
优点是因为已经固定了每个代码的书写位置,所有人写出来的代码风格都差不多。
缺点是当单个组件的逻辑复杂到一定程度时,代码就会显得特别笨重,非常不灵活。
随意的写组合式API
vue3推出了组合式 (Composition) API
,他的主要特点就是非常灵活。解决了选项式API不够灵活的问题。但是灵活也是一把双刃剑,因为每个开发的编码水平不同。所以就出现了有的人使用组合式 (Composition) API写出来的代码非常漂亮和易维护,有的人写的代码确实很混乱和难易维护。
比如一个组件开始的时候还是规规矩矩的写,所有的ref
响应式变量放在一块,所有的方法放在一块,所有的computed
计算属性放在一块。
但是随着项目的不断迭代 ,或者干脆是换了一个人来维护。这时的代码可能就不是最开始那样清晰了,比如新加的代码不管是ref
、computed
还是方法都放到一起去了。如下图:
只有count1
和count2
时,代码看着还挺整齐的。但是随着count3
的代码加入后看着就比较凌乱了,后续如果再加count4
的代码就会更加乱了。
有序的写组合式API
为了解决上面的问题,所以我们约定了一个代码规范。同一种API的代码全部写在一个地方,比如所有的props
放在一块、所有的emits
放在一块、所有的computed
放在一块。并且这些模块的代码都按照约定的顺序去写,如下图:
随着vue组件的代码增加,上面的方案又有新的问题了。
还是前面的那个例子比如有5个count
的ref
变量,对应的computed
和methods
也有5个。此时我们的vue组件代码量就很多了,比如此时我想看看computed1
和increment1
的逻辑是怎么样的。
因为computed1
和increment1
函数分别在文件的computed
和methods
的代码块处,computed1
和increment1
之间隔了几十行代码,看完computed1
的代码再跳转去看increment1
的代码就很痛苦。如下图:
这时有小伙伴会说,抽成hooks
呗。这里有5个count
,那么就抽5个hooks
文件。像这样的代码。如下图:
一般来说抽取出来的hooks
都是用来多个组件进行逻辑共享,但是我们这里抽取出来的useCount
文件明显只有这个vue组件会用他。达不到逻辑共享的目的,所以单独将这些逻辑抽取成名为useCount
的hooks
文件又有点不合适。
最终解决方案
我们不如将前面的方案进行融合一下,抽取出多个useCount
函数放在当前vue组件内,而不是抽成单个hooks
文件。并且在多个useCount
函数中我们还是按照前面约定的规范,按照顺序去写ref
变量、computed
、函数的代码。
最终得出的最佳实践如下图:
上面这种写法有几个优势:
- 我们将每个
count
的逻辑都抽取成单独的useCount
函数,并且这些函数都在当前vue文件中,没有将其抽取成hooks
文件。如果哪天useCount1
中的逻辑需要给其他组件使用,我们只需要新建一个useCount
文件,然后直接将useCount1
函数的代码移到新建的文件中就可以了。 - 如果我们想查看
doubleCount1
和increment1
中的逻辑,只需要找到useCount1
函数,关于count1
相关的逻辑都在这个函数里面,无需像之前那样翻山越岭跨越几十行代码才能从doubleCount1
的代码跳转到increment1
的代码。
总结
本文介绍了使用Composition API
的最佳实践,规则如下:
- 首先约定了一个代码规范,
Composition API
按照约定的顺序进行书写(书写顺序可以按照公司代码规范适当调整)。并且同一种组合式API的代码全部写在一个地方,比如所有的props
放在一块、所有的emits
放在一块、所有的computed
放在一块。 - 如果逻辑能够多个组件复用就抽取成单独的
hooks
文件。 - 如果逻辑不能给多个组件复用,就将逻辑抽取成
useXXX
函数,将useXXX
函数的代码还是放到当前组件中。
第一个好处是如果某天
useXXX
函数中的逻辑需要给其他组件复用,我们只需要将useXXX
函数的代码移到新建的hooks
文件中即可。
第二个好处是我们想查看某个业务逻辑的代码,只需要在对应的
useXXX
函数中去找即可。无需在整个vue文件中翻山越岭从computed
模块的代码跳转到function
函数的代码。
最后推荐一下欧阳自己写的开源电子书vue3编译原理揭秘,看完这本书可以让你对vue编译的认知有质的提升,并且这本书初、中级前端能看懂。完全免费,只求一个star。
来源:juejin.cn/post/7398046513811095592
为什么同事的前端代码我改不动了?
《如何写出高质量的前端代码》学习笔记
在日常开发中,我们经常会遇到需要修改同事代码的情况。有时可能会花费很长时间却只改动了几行代码,而且改完后还可能引发新的bug。我们聊聊导致代码难以维护的常见原因,以及相应的解决方案。
常见问题及解决方案
1. 单文件代码过长
问题描述:
- 单个文件动辄几千行代码
- 包含大量DOM结构、JS逻辑和样式
- 需要花费大量时间才能理解代码结构
解决方案: 将大文件拆分成多个小模块,每个模块负责独立的功能。
以一个品牌官网为例,可以这样拆分:
<template>
<div>
<Header/>
<main>
<Banner/>
<AboutUs/>
<Services/>
<ContactUs/>
</main>
<Footer/>
</div>
</template>
2. 模块耦合严重
问题描述:
- 模块之间相互依赖
- 修改一处可能影响多处
- 难以进行单元测试
❌ 错误示例:
<script>
export default {
methods: {
getUserDetail() {
// 错误示范:多处耦合
let userId = this.$store.state.userInfo.id
|| window.currentUserId
|| this.$route.params.userId;
getUser(userId).then(res => {
// 直接操作子组件内部数据
this.$refs.userBaseInfo.data = res.baseInfo;
this.$refs.userArticles.data = res.articles;
})
}
}
}
</script>
✅ 正确示例:
<template>
<div>
<userBaseInfo :base-info="baseInfo"/>
<userArticles :articles="articles"/>
</div>
</template>
<script>
export default {
props: ['userId'],
data() {
return {
baseInfo: {},
articles: []
}
},
methods: {
getUserDetail() {
getUser(this.userId).then(res => {
this.baseInfo = res.baseInfo;
this.articles = res.articles;
})
}
}
}
</script>
3. 职责不单一
问题描述:
- 一个方法承担了多个功能
- 代码逻辑混杂在一起
- 难以复用和维护
❌ 错误示例:
<script>
export default {
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
// 一个方法中做了太多事情
let vipCount = 0;
let activeVipsCount = 0;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(user.type === 'vip') {
vipCount++
}
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})
this.vipCount = vipCount;
this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
✅ 正确示例:
<script>
export default {
computed: {
// 将不同统计逻辑拆分为独立的计算属性
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
// 方法只负责获取数据
userService.getUserList().then(res => {
this.userData = res.data;
})
}
}
}
</script>
4. 代码复制代替复用
问题描述:
- 发现相似功能就直接复制代码
- 维护时需要修改多处相同的代码
- 容易遗漏修改点,造成bug
解决方案:
- 提前抽取公共代码
- 将重复逻辑封装成独立函数或组件
- 通过参数来处理细微差异
5. 强行复用/假装复用
问题描述:
将不该复用的代码强行糅合在一起,比如:
- 将登录弹窗和修改密码弹窗合并成一个组件
- 把一个实体的所有操作(增删改查)都塞进一个方法
❌ 错误示例:
<template>
<div>
<UserManagerDialog ref="UserManagerDialog"/>
</div>
</template>
<script>
export default {
methods: {
addUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'add'
})
},
editName() {
this.$refs.UserManagerDialog.showDialog({
type: 'editName'
})
},
deleteUser() {
this.$refs.UserManagerDialog.showDialog({
type: 'delete'
})
}
}
}
</script>
✅ 正确做法:
- 不同业务逻辑使用独立组件
- 只抽取真正可复用的部分(如表单验证规则、公共UI组件等)
- 保持每个组件职责单一
6. 破坏数据一致性
问题描述: 使用多个关联状态来维护同一份数据,容易造成数据不一致。
❌ 错误示例:
<script>
export default {
data() {
return {
sourceData: [], // 原始数据
tableData: [], // 过滤后的数据
name: '', // 查询条件
type: ''
}
},
methods: {
nameChange(name) {
this.name = name;
// 手动维护 tableData,容易遗漏
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
},
typeChange(type) {
this.type = type;
// 重复的过滤逻辑
this.tableData = this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
);
}
}
}
</script>
✅ 正确示例:
<script>
export default {
data() {
return {
sourceData: [],
name: '',
type: ''
}
},
computed: {
// 使用计算属性自动维护派生数据
tableData() {
return this.sourceData.filter(item =>
(!this.name || item.name === this.name) &&
(!this.type || item.type === this.type)
)
}
}
}
</script>
7. 解决方案不“正统”
问题描述:
使用不常见或不合理的方案解决问题,如:
- 直接修改 node_modules 中的代码,更好的实践:
- 优先使用框架/语言原生解决方案
- 遵循最佳实践和设计模式
- 进行方案评审和代码审查
- 对于第三方库的 bug:
- 向作者提交 issue 或 PR
- 将修改后的包发布到企业内部仓库
- 寻找替代方案
- 使用 JS 实现纯 CSS 可实现的效果
❌ 错误示例:
// 不恰当的鼠标悬停效果实现
element.onmouseover = function() {
this.style.color = 'red';
}
element.onmouseout = function() {
this.style.color = 'black';
}
✅ 正确示例:
/* 使用 CSS hover 伪类 */
.element:hover {
color: red;
}
- 过度使用全局变量
如何进行代码重构
重构的原则
- 不改变软件功能
- 小步快跑,逐步改进
- 边改边测试
- 随时可以暂停
重构示例
以下展示如何一步步重构上面的统计代码:
第一步:抽取 vipCount
- 删除data中的vipCount
- 增加计算属性vipCount,将getUserData中关于vipCount的逻辑挪到这里
- 删除getUserData中vipCount的计算逻辑
<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
let activeVipsCount = 0;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
if(user.type === 'vip') {
activeVipsCount++
}
activeUsersCount++
}
})
this.activeVipsCount = activeVipsCount;
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
完成本次更改后,测试下各项数据是否正常,不正常查找原因,正常我们继续。
第二步:抽取 activeVipsCount
- 删除data中的activeVipsCount
- 增加计算属性activeVipsCount,将getUserData中activeVipsCount的计算逻辑迁移过来
- 删除getUserData中关于activeVipsCount计算的代码
<script>
export default {
computed: {
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.userData.filter(user =>
user.type === 'vip' &&
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
).length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
let activeUsersCount = 0;
this.userData.forEach(user => {
if(dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))) {
activeUsersCount++
}
})
this.activeUsersCount = activeUsersCount;
})
}
}
}
</script>
...
最终版本:
<script>
export default {
computed: {
activeUsers() {
return this.userData.filter(user =>
dayjs(user.loginTime).isAfter(dayjs().subtract(30, 'day'))
)
},
vipCount() {
return this.userData.filter(user => user.type === 'vip').length
},
activeVipsCount() {
return this.activeUsers.filter(user => user.type === 'vip').length
},
activeUsersCount() {
return this.activeUsers.length
}
},
methods: {
getUserData() {
userService.getUserList().then(res => {
this.userData = res.data;
})
}
}
}
</script>
总结
要写出易维护的代码,需要注意:
- 合理拆分模块,避免单文件过大
- 降低模块间耦合
- 保持职责单一
- 使用计算属性处理派生数据
- 定期进行代码重构
记住:重构是一个渐进的过程,不要试图一次性完成所有改进。在保证功能正常的前提下,通过小步快跑的方式逐步优化代码质量。
来源:juejin.cn/post/7438647460219961395
Android逆向之某影音app去广告
前言
本文介绍通过抓包的方式,分析出某影音app的去广告逆向点,难度极低,适合新手上路。
所谓逆向,三分逆,七分猜。
分析过程
首先打开app,可以看到不时有广告弹出。我们的目标就是去除这些广告。
首先想到的思路是定位到加载广告的代码删掉即可,使用 MT 管理器查看安装包的 dex 文件,可以看到大量 a、b、c 的目录,可见代码被混淆过的,直接上手分析太费劲了。
接着猜测,既然 app 能动态加载各种广告,必然会发起 http 网络请求,只需要分析出哪些请求是和广告相关的,将其拦截,即可实现去广告的目的。
所以接下来尝试抓包分析一下。
抓 http 请求推荐使用 Burp Suite,使用社区版即可。
打开 Burp Suite,切换到 Proxy 页。Proxy 即创建一个代理服务器,配置所有的网络请求连接到这个代理服务器,就可以看到所有经过代理服务器的 http 请求,并且能拦截修改请求、丢弃请求。
打开 Proxy settings,编辑默认的代理服务器地址配置。
端口号我这里填写 8888,地址选择当前机器的 ip 地址,与 ipconfig 命令显示的 ip 保持一致。
确定后选择导出 DER 格式的证书。
任意取名,文件扩展名为 .cer。
由于抓包需要电脑与手机在同一网络环境下, 因此建议使用安卓模拟器。
将 cer 文件导入到安卓模拟器中,之后打开设置 - 安全 - 加密与凭据 - 从SD卡安装(不同安卓会有所不同)。
选择 cer 文件后,随意命名,凭据用途选择 WLAN。确定后安装成功。
编辑当前连接的 wifi,设置代理为手动,主机名和端口填我们在 Burp Suite 中填写的内容。
打开 Burp Suite 打开代理拦截。
此时重新打开app,可以看到 Burp Suite 成功拦截了一条网络请求,并且app卡在启动页上。
此时点击 Drop (丢弃该请求),该请求会重发又被拦截,全部 Drop 掉。
此时惊喜的发现,进入了app首页,并且没有任何广告弹窗了。
由此可见,启动app时首先会加载 json 配置,根据配置去加载广告,只要将这条请求去掉就可以达到去广告的目的。只需要到app中反编译搜索拦截到的请求 url ,即可定位到拉取广告的代码。
搜索 sjmconfig,即可定位到目标代码。
将域名修改为 localhost,那么这条请求将永远不会成功。
之后保存修改、签名,重新安装,完事收工。
来源:juejin.cn/post/7343139490901737482
31 岁,从雄心壮志到回归平凡
一、前言
不久前,有位 31 岁的兄弟找我聊天,他结束了在深圳漂泊不定的生涯,回老家尝试自媒体创业,最终以失败告终,无奈又重回深圳找工作。
二、为何做出这个决定
他的这一决定并非一时冲动,其导火索可追溯至 2022 年。当时的大环境可谓一片混乱,人人都在拼命内卷,下班时间越来越晚,几乎与月亮同步。
每逢放假回老家,亲戚朋友便会念叨:“你在深圳 996 累得不成样子,究竟图个啥?工资是高些,可这日子有啥乐趣?再者,你在深圳漂泊不定,何时才是尽头?房子买不了,户口落不下,迟早得打道回府。”
这位兄弟听后,心中满是酸楚,思量着:多年来为人当牛做马,还遭受 PUA,还得时刻担忧 35 岁被辞退,这日子实在过不下去了!于是,心中犹如埋下了一颗炸弹:我得去另一番天地闯荡!
实际上,这位老兄本就向往自由,那句“打工是不可能打工的,这辈子都不会打工”如同他的精神支柱,一直渴望有朝一日不再打工。
所以在 2022 年底,他毅然决定:2023 年,我要去新的天地看看!拿完年终奖就离开!去创业,开启全新人生!
在准备辞职之前,还有几件糟心事,让他坚定了辞职的决心:
当时尚未完全放开,在家线上办公。在公司上班起码是 995,晚上 9 到 10 点下班基本能休息。可在家就惨了,各种电话、视频会议如轰炸机般袭来,十一二点还得开会,生活与工作完全混乱。每次听到会议呼叫,他的心就像被针扎,默默祈求别出岔子,都快被逼出精神病了。
感染新冠时,他只敢请一天假,第二天晕乎乎地继续工作。为何?他心里明白,落下的工作最终还得自己加班完成,否则会被领导骂得狗血淋头。
周末也不得安宁,需随时在线,群消息要秒回,线上问题得立刻解决,不然就会被贴上“工作态度差”的标签,绩效惨不忍睹。
三、他辞职了
终于熬到 2023 年,年终奖一到手,这位兄弟便决定离职。
当时身边有人如唐僧般劝他别辞职,说当下大环境糟糕,千万别放弃本行去搞自媒体。
这兄弟心中充满不屑,暗想:程序员最多干到 35 岁,如今内卷严重,加班致使身体近乎垮掉,这行年老无出路!
当然,这位兄弟并非头脑发热就辞职搞自媒体,辞职前做了充分准备,调研分析了许久:
作为互联网人,搞实体店一窍不通,只能在互联网领域筛选。互联网项目繁多:个人工具站、知乎好物、闲鱼二手书、小红书带货、抖音带货、抖音个人 IP、公众号写作、短剧 CPS、小说推文、知识付费等等,看得他眼花缭乱。最终发现抖音流量最大,从事与抖音相关的或许更容易些。
而且这位老兄还学习了众多创业知识,如寻找对标、分析商业模式,参加了不少知识付费的圈子,还报名了小红书和抖音的培训班,前前后后花费了一万多元,心痛不已。
四、想象很美好
为了这次创业,这位兄弟制定了一系列计划,涵盖年度、季度、月度、周度,甚至每日的计划都规划得极其详尽。
他还发誓要变得极度自律,每天按时起床,锻炼、学习、开展业务,决心坚如磐石,此次抱着必胜的信念放手一搏!
当然,他也提前预料到可能遭遇的风险,并绞尽脑汁思考应对之策:
例如项目进展缓慢如何处理,拖延症发作怎样解决,家人反对如何应对,朋友约饭打乱计划怎么办,遇到难题又该怎样解决等等。
这一系列举措下来,他认为万事俱备,只待大干一场!
五、现实很残酷
辞职后,他依照计划开始早睡早起,锻炼、学习,忙于创业之事。
然而,未曾料到,很快就被现实狠狠打击,这是他创业路上遇到的首个难题,也是他始料未及的。
就在刚创业不久,他竟然患上了焦虑症,还伴有严重的失眠。
他万万没想到会被失眠困扰。原本以为,摆脱上班的压力,工作时间自由安排,无人 PUA,还有时间锻炼,应当能睡个安稳觉。
但实际情况并非如此,对于我们这种从小被学校管,长大后被公司管的普通人而言,创业竟是这般模样:
他忙得晕头转向,比上班更累,因为以前只需做好本职工作,如今所有事情都需亲力亲为。以做自媒体为例,从账号定位、内容选题、写脚本,到置景、拍摄、后期剪辑,再到选品、商务对接、客服,最后到用户社群运营,所有环节都得独自承担。视频没流量、违规、付费转化率低等问题,都需自己琢磨解决。以前在公司,按要求完成任务即可.
面对大量的自由时间,他全然不知如何安排,诸多环节都是陌生领域,需要学习的太多,每天看似忙碌,却不见成果,怎能不感到沮丧?以前只从事熟悉工作,产出有保障.
与社会脱节,缺乏存在感和归属感(这是人类的基本需求之一),不属于任何群体,无人夸赞、尊重、接纳,甚至想被责骂都无人理会。以前在公司,表现好会得到称赞,表现不佳会获得建议,至少有人可供倾诉、交流、求助。
没有收入,眼睁睁看着钱包逐渐干瘪,怎能不焦虑?更焦虑的是,不知未来何时能盈利。更更焦虑的是,不知最终能否盈利。以前工作再累,至少有工资,有生活保障。
所以在此奉劝那些有裸辞创业想法的人,切勿裸辞!裸辞创业可谓九死一生!正确的做法应是一边工作一边开展副业,待副业收入与工资相当甚至超过工资时,再辞职。有人或许会说,工作繁忙,哪有时间搞副业。他曾经也这么想,但现在他告诉你:没时间就挤出时间,每天晚睡或早起一会儿,周末也抽出时间。这点问题都无法解决?创业遇到的难题可比这困难十倍!若觉得这都难以做到,那还是老老实实打工吧。
可他已经裸辞,别无他法,只能硬着头皮解决问题。他开始服用助眠药,喝中药,情况稍有改善,但未完全康复,只是比之前稍好一些。
就这样拖着疲惫的身躯,他坚持了半年多,一半时间学习,一半时间实践,创建了两个自媒体号,第一个号因违规被封,第二个号流量也毫无起色。这条创业之路越走越艰难,每天晚上都不愿入睡,因为害怕明天的到来,因为一睁眼,眼前仍是一片黑暗。
最终,在创业的巨大压力、8 个月没有收入的恐慌、焦虑失眠心悸的折磨下,他选择了放弃。
失败了,败得一塌糊涂。回顾这次经历,仿佛之前在一艘航行的货轮上工作,实在无法忍受船上的种种压迫,鼓足勇气,带着一艘救生艇跳海,追求向往的自由。结果高估了自身当时的能力,难以抵御大海的狂风巨浪,翻船了……差点就葬身大海……
六、重新找工作
放弃后的几周,他开始熬夜,暴饮暴食,之前的运动也停止了。整天在家拉上窗帘,除了吃饭就是躺在床上刷手机,试图分散注意力,减轻内心的痛苦。
但这样下去并非长久之计,如今肯定不想再触及创业,只能先找份工作。
刚开始找工作时,他心有不甘,因为三线城市与深圳相比,无论是工作机会、环境,还是薪资,都相差甚远。
但无奈,他感觉自己如同即将溺水之人,急需一根救命稻草,先生存下来再说,这是当前的首要任务。
于是在网上大量投递简历,结果惨不忍睹,几乎没有几家公司招聘,前后折腾了一个月,真正靠谱的面试仅有一家,没错,仅有一家。
好在这家公司他顺利拿到了 offer,是一家刚创业的小公司,仅有十几个人,薪资仅有原来的一半多些,不过拿到 offer 的那一刻他还是有些激动,感觉自己又活过来了,不管怎样,能缓口气了。
七、迷茫的未来
如今上班已一个多月,公司还不错,不加班,基本 7 点前大家就都下班了,离家很近,骑共享单车 10 分钟左右就能到。这一个月,焦虑消失了,心悸不再,失眠也好了。每天按部就班地上班下班,完成老板交代的任务,其他事情无需操心,又做起了熟悉且擅长的工作。
回望过去,人生的旅途上,翻过一座山,或许迎接我们的仍是连绵不绝的山峦,海的尽头似乎总是遥不可及,甚至可能一生都无法亲眼目睹。此刻的我们,更愿意将心态放平,珍惜眼前,脚踏实地地前行,每一步都充满探索与希望。
三十一岁,一个不再轻易言弃的年纪,我学会了与自己和解,正视并接纳自己的平凡。
来源:juejin.cn/post/7395962823502299170
HTML到PDF转换,11K Star 的pdfmake.js轻松应对
在Web开发中,将HTML页面转换为PDF文件是一项常见的需求。无论是生成报告、发票、还是其他任何需要打印或以PDF格式分发的文档,开发者都需要一个既简单又可靠的解决方案。幸运的是,pdfmake.js
库以其轻量级、高性能和易用性,成为了许多开发者的首选。本文将介绍如何使用这个拥有11K Star的GitHub项目来实现HTML到PDF的转换。
什么是pdfmake.js
pdfmake.js
是一个基于JavaScript的库,用于在客户端和服务器端生成PDF文档。它允许开发者使用HTML和CSS来设计PDF文档的布局和样式,使得创建复杂的PDF文档变得异常简单。
为什么选择pdfmake.js
pdfmake.js
的文件大小仅为11KB(压缩后),这使得它成为Web应用中一个非常轻量级的解决方案- 拥有超过11K Star的GitHub项目,
pdfmake.js
得到了广泛的社区支持和认可,稳定性和可靠性值得信任 - 功能丰富,它支持表格、列表、图片、样式、页眉页脚等多种元素,几乎可以满足所有PDF文档的需求。
pdfmake.js
可以轻松集成到任何现有的Web应用中,无论是使用Node.js、Angular、React还是Vue.js。
快速开始
安装
通过npm安装pdfmake.js
非常简单:
npm install pdfmake
或者,如果你使用yarn:
yarn add pdfmake
创建PDF文档
创建一个PDF文档只需要几个简单的步骤:
- 引入pdfmake.js
import pdfMake from 'pdfmake/build/pdfmake';
//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};
- 定义文档内容
const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
- 创建PDF
const pdf = pdfMake.createPdf(dd);
pdf.getBlob((buffer) => {
const file = new File([blob], filename, { type: blob.type })
//上传服务器
});
//或直接下载
pdf.download('文件名.pdf')
生成的pdf效果:
想动手体验,请访问pdfmake.org/playground.…。
html-to-pdfmake 强强联合
当PDF文档内容非固定,content字段内的结构要随时可变,不能再像下方代码块一样写死,html-to-pdfmake
即为解决这类问题而产生的。
const dd = {
content: [
'Hello, 我是程序员凌览',
{ text: 'This is a simple PDF document.', fontSize: 12 },
{ text: 'It is generated using pdfmake.js.', bold: true }
],
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
安装
通过npm安装:
npm install html-to-pdfmake
或者,如果你使用yarn:
yarn add html-to-pdfmake
HTML字符串转pdfmake格式
- 引入
html-to-pdfmake
import pdfMake from 'pdfmake/build/pdfmake';
import htmlToPdfmake from 'html-to-pdfmake';
//引入中文字体,避免转换的PDF中文乱码
pdfMake.fonts = {
AlibabaPuHuiTi: {
normal: 'https://xx/AlibabaPuHuiTi-3-55-Regular.ttf',
bold: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf',
italics: 'https://xxx/AlibabaPuHuiTi-3-55-Regular.ttf',
bolditalics: 'https://xxx/AlibabaPuHuiTi-3-65-Medium.ttf'
}
};
//它会返回pdfmake需要的数据结构
const html = htmlToPdfmake(`
<div>
<h1>程序员凌览</h1>
<p>
This is a sentence with a <strong>bold word</strong>, <em>one in italic</em>,
and <u>one with underline</u>. And finally <a href="https://www.somewhere.com">a link</a>.
</p>
</div>
`);
- 使用
html-to-pdfmake
转换的数据结构
const dd = {
content:html.content,
//设置默认字体
defaultStyle: {
font: 'AlibabaPuHuiTi'
},
};
const pdf = pdfMake.createPdf(dd);
pdf.download()
生成的pdf效果:
添加图片要额外设置:
const ret = htmlToPdfmake(`<img src="https://picsum.photos/seed/picsum/200">`, {
imagesByReference:true
});
// 'ret' contains:
// {
// "content":[
// [
// {
// "nodeName":"IMG",
// "image":"img_ref_0",
// "style":["html-img"]
// }
// ]
// ],
// "images":{
// "img_ref_0":"https://picsum.photos/seed/picsum/200"
// }
// }
const dd = {
content:ret.content,
images:ret.images
}
pdfMake.createPdf(dd).download();
最后
通过上述步骤,我们可以看到pdfmake.js
及其配套工具html-to-pdfmake
为Web开发者提供了一个强大而灵活的工具,以满足各种PDF文档生成的需求。无论是静态内容还是动态生成的内容,这个组合都能提供简洁而高效的解决方案。
程序员凌览的技术网站linglan01.cn/;关注公粽号【程序员凌览】回复"1",获取编程电子书
来源:juejin.cn/post/7376894518330359843
Dart 脚本:flutter 应用一键打包+发布
引言
近期整理技术栈,将陆续总结分享一些项目实战中用到的实用工具。本篇分享开发流程中必不可缺的一个环节,提测。
闲聊一下
当然,对于打包,我们可以直接使用命令行直接打包例如:
flutter build apk --verbose
只是,相比输入命令行,我更倾向于一键操作
,更倾向写一个打包脚本,我可以在脚本里编辑个性化操作,例如:瘦身、修改产物(apk、ipa)名称、指定打包成功后事务等等。
比如在项目里新建一个文件夹,如 script
, 当需要打包发布时,右键 Run 就 Ok 了。
下面,小编整理了基础款 dart 脚本,用于 打包
和 上传蒲公英
。有需要的同学可自行添加个性化处理。
Android 打包脚本
该脚本用于 apk 打包,apk 以当前时间戳名称,打包成功后可选直接打开文件夹或发布蒲公英。
import 'dart:io';
import 'package:intl/intl.dart';
import 'package:yaml/yaml.dart' as yaml;
import 'pgy_tool.dart'; //蒲公英发布脚本,下面会给出
void main(List<String> args) async {
//是否上传蒲公英
bool uploadPGY = true;
// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');
final process = await Process.start(
'flutter',
[
'build',
'apk',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);
final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('打包失败,请查看日志');
return;
}
process.kill();
//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;
//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();
// apk 的输出目录
final apkDirectory = '$projectPath/build/app/outputs/flutter-apk/';
const buildAppName = 'app-release.apk';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);
final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();
final resultAppName = '${resultNameList.join('_')}.apk';
final appPath = apkDirectory + resultAppName;
//重命名apk文件
final apkFile = File(apkDirectory + buildAppName);
await apkFile.rename(appPath);
stdout.write('apk 打包成功 >>>>> $appPath \n');
if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'android',
);
final uploadSuccess = await pgyPublisher.publish(appPath);
if (uploadSuccess) {
File(appPath).delete();
}
} else {
// 直接打开文件
await Process.run(
'open',
[apkDirectory],
);
}
}
Ipa 打包脚本
ipa 打包脚本和 apk 打包脚本类似,只是过程中多了一步操作,删除之前的构建文件,如下:
import 'dart:io';
import 'package:yaml/yaml.dart' as yaml;
import 'package:intl/intl.dart';
import 'pgy_tool.dart';
void main() async {
const originIpaName = '你的应用名称';
//是否上传蒲公英
bool uploadPGY = true;
// 获取项目根目录
final _projectPath = await Process.run(
'pwd',
[],
);
final projectPath = (_projectPath.stdout as String).replaceAll(
'\n',
'',
);
// 控制台打印项目目录
stdout.write('项目目录:$projectPath 开始编译\n');
// 编译目录
final buildPath = '$projectPath/build/ios';
// 切换到项目目录
Directory.current = projectPath;
// 删除之前的构建文件
if (Directory(buildPath).existsSync()) {
Directory(buildPath).deleteSync(
recursive: true,
);
}
final process = await Process.start(
'flutter',
[
'build',
'ipa',
'--target=$projectPath/lib/main.dart',
'--verbose',
],
workingDirectory: projectPath,
mode: ProcessStartMode.inheritStdio,
);
final buildResult = await process.exitCode;
if (buildResult != 0) {
stdout.write('ipa 编译失败,请查看日志');
return;
}
process.kill();
stdout.write('ipa 编译成功!\n');
//开始重命名
final file = File('$projectPath/pubspec.yaml');
final fileContent = file.readAsStringSync();
final yamlMap = yaml.loadYaml(fileContent) as yaml.YamlMap;
//获取当前版本号
final version = (yamlMap['version'].toString()).replaceAll(
'+',
'_',
);
final appName = yamlMap['name'].toString();
// ipa 的输出目录
final ipaDirectory = '$projectPath/build/ios/ipa/';
const buildAppName = '$originIpaName.ipa';
final timeStr = DateFormat('yyyyMMddHHmm').format(
DateTime.now(),
);
final resultNameList = [
appName,
version,
timeStr,
].where((element) => element.isNotEmpty).toList();
final resultAppName = '${resultNameList.join('_')}.ipa';
final appPath = ipaDirectory + resultAppName;
//重命名ipa文件
final ipaFile = File(ipaDirectory + buildAppName);
await ipaFile.rename(appPath);
stdout.write('ipa 打包成功 >>>>> $appPath \n');
if (uploadPGY) {
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);
} else {
// 直接打开文件
await Process.run(
'open',
[ipaDirectory],
);
}
}
蒲公英发布脚本
上面打包脚本中上传到蒲公英都调用了这句代码:
// 上传蒲公英
final pgyPublisher = PGYTool(
apiKey: '蒲公英控制台内你的应用的apiKey',
buildType: 'ios',
);
pgyPublisher.publish(appPath);
appPath
:就是打包成功的 apk/ipa 本地路径buildType
:分别对应两个值 android、iosapiKey
:蒲公英控制台内你的应用对应的apiKey,如下所示
PGYTool 对应的发布脚本如下:
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:rxdart/rxdart.dart';
// 蒲公英工具类
class PGYTool {
final getTokenPath = 'https://www.pgyer.com/apiv2/app/getCOSToken';
final getAppInfoPath = 'https://www.pgyer.com/apiv2/app/buildInfo';
final String apiKey;
final String buildType; //android、ios
PGYTool({
required this.apiKey,
required this.buildType,
});
//发布应用
Future<bool> publish(String appFilePath) async {
final dio = new Dio();
stdout.write('开始获取蒲公英token');
final tokenResponse = await _getToken(dio);
if (tokenResponse == null) {
stdout.write('>>>>>> 获取token失败 \n');
return false;
}
stdout.write('>>>>>> 获取token成功 \n');
final endpoint = tokenResponse['data']['endpoint'] ?? '';
final params = tokenResponse['data']['params'] ?? {};
stdout.write('蒲公英上传地址:$endpoint\n');
Map<String, dynamic> map = {
...params,
};
map['file'] = await MultipartFile.fromFile(appFilePath);
final controller = StreamController<MapEntry<int, int>>();
controller.stream
.throttleTime(const Duration(seconds: 1), trailing: true)
.listen(
(event) => stdout.write(
'${event.key}/${event.value} ${(event.key.toDouble() / event.value.toDouble() * 100).toStringAsFixed(2)}% \n',
),
onDone: () {
controller.close();
},
onError: (e) {
controller.close();
},
);
final uploadRsp = await dio.post(
endpoint,
data: FormData.fromMap(map),
onSendProgress: (count, total) {
controller.sink.add(
MapEntry<int, int>(
count,
total,
),
);
},
);
await Future.delayed(const Duration(seconds: 1));
if (uploadRsp.statusCode != 204) {
stdout.write('>>>>> 蒲公英上传失败 \n');
return false;
}
stdout.write('>>>>> 蒲公英上传成功 \n');
await Future.delayed(const Duration(seconds: 3));
await _getAppInfo(dio, tokenResponse['data']['key']);
return true;
}
// 获取蒲公英token
Future<Map<String, dynamic>?> _getToken(Dio dio) async {
Response<Map<String, dynamic>>? tokenResponse;
try {
tokenResponse = await dio.post<Map<String, dynamic>>(
getTokenPath,
queryParameters: {
'_api_key': apiKey,
'buildType': buildType,
},
);
} catch (_) {
stdout.write('_getToken error : $_');
}
if (tokenResponse == null) return null;
final responseJson = tokenResponse.data ?? {};
final tokenCode = responseJson['code'] ?? 100;
if (tokenCode != 0) {
return null;
} else {
return responseJson;
}
}
// tokenKey 是获取token中的返回值Key
Future<void> _getAppInfo(Dio dio, String tokenKey, {int retryCount = 3}) async {
final response = await dio.get<Map<String, dynamic>>(
getAppInfoPath,
queryParameters: {
'_api_key': apiKey,
'buildKey': tokenKey,
},
).then((value) {
return value.data ?? {};
});
final responseCode = response['code'];
if (responseCode == 1247 && retryCount > 0) {
//应用正在发布中,间隔 3 秒重新获取
stdout.write('>>>>> 应用正在发布中,间隔 3 秒重新获取发布信息\n');
await Future.delayed(const Duration(seconds: 3));
return _getAppInfo(dio, tokenKey, retryCount: retryCount - 1);
}
final appName = response['data']['buildName'];
final appVersion = response['data']['buildVersion'];
final appUrl = response['data']['buildShortcutUrl'];
final updateTime = response['data']['buildUpdated'];
if (appName != null) {
stdout.write('$appName 版本更新($appVersion)\n');
stdout.write('下载地址:https://www.pgyer.com/$appUrl\n');
stdout.write('更新时间:$updateTime\n');
}
}
}
运行发布脚本后,控制台会将应用的上传成功后的下载地址打印出来。
来源:juejin.cn/post/7304538454875586587
在微信小程序里运行完整的 Flutter,我们是怎么做到的?
背景
小程序是一种全新的业务形态,特别是微信小程序,既结合了 Web 动态化特性,又拥有 Native 丰富的设备能力支持。
在微信这个宿主上,小程序不仅有稳定的分发渠道,更拥有完善的生命周期、数据、AI 能力支持。
在该微信上开发小程序,一般使用以下两种方法:
- JavaScript + WXML + WCSS
- Taro + React + JavaScript
本文要介绍的是使用 Flutter Framework 开发小程序的方法,以及该方法背后的技术原理。
技术挑战
尽管 Flutter 官方已经提供 Flutter Web 实现,Flutter Web 本身就是基于 dart2js 运行的,微信小程序可以运行 JavaScript,在原理上跑起 Flutter Web 是没有问题的。
但仍然存在以下技术挑战:
- 微信小程序没有 W3C 标准的 JavaScript 对象,Flutter Web 不能直接运行。
- 微信小程序也没有 DOM 实现,Flutter Web HTML Renderer 不能直接渲染。
- 微信小程序对包大小的限制十分严格,主包不能超过 2M,而 Flutter Web 所编译的 main.dart.js 初始体积就有 1.3 M,必须有合理的分包机制才能上传。
我们在 MPFlutter 1.x 版本中,针对上述问题已有一定的探索,1.x 版本的解决方法如下:
- 使用微信开源的 kbone 库,模拟 W3C 实现,并通过模拟的 DOM 对象渲染出符合 WXML 要求的视图树。
- 通过 Shadow Element Tree 的方式,使用 JSON 在 Dart 与 JavaScript 上下文同步视图树。
- Fork Flutter Framework,并对其进行外科手术式的裁剪,使 main.dart.js 初始体积降低到 600K。
MPFlutter 1.x 方案已经良好的运行了两年,也收到了开发者非常多的反馈,开发者常诟病于裁剪后的 Flutter Framework 不兼容 Flutter 生态上的插件,同时 material 库也无法使用,需要从头开始编写 UI。
在 MPFlutter 2.0 版本,我们重新思考在小程序上运行 Flutter 的最佳方式,并在最终使用 CanvasKit Renderer 解决以上全部问题。
技术方案
Summary
通过裁剪 Skia 生成符合微信小程序分包要求的 CanvasKit,使用 Flutter Web + W3C BOM + WebGL Canvas 跑通渲染流程。
技术选型
在介绍技术选型前,需要先介绍 Flutter Web 的两种 Renderer。
HTML Renderer
原理是 Flutter Framework 通过 dart:js 库调用 Document 对象,并基于此将各种 RenderObject 转换为对应的 Element + CSS 添加到 DOM 树中。
该方案优点在于兼容性很好,几乎没有额外的依赖;缺点是性能不佳,并且渲染内容一致性难以与 Native Flutter 对齐。
CanvasKit Renderer
原理是通过 WebGL + Skia 渲染界面,该渲染方式与 Native Flutter 是完全一致的。
该方案优点在于渲染性能非常好,一致性与 Native Flutter 几乎没有差别;缺点是内存占用大,且需要从远端加载字体。
MPFlutter 2.0 选型
我们在 1.x 版本中用的是 HTML Renderer,通过 kbone 运行的 DOM 模拟层存在很多的问题,最令人诟病的是数据更新后界面刷新慢。当然问题的并不在于 kbone,而是 MPFlutter 1.x 本身对于 Element Tree 的序列化、反序列化的处理存在天然的缺陷,尽管已经通过 Dirty 和 Diff 等手段优化。
在 2.x 版本中,我们直接抛弃 HTML Renderer 的想法,使用 CanvasKit Renderer。
使用 CanvasKit Renderer 有这几个大前提:
- 微信小程序已支持 WebAssembly 并支持 Brotli 压缩;
- 微信小程序 Canvas 的性能相比最初的版本有质的提升,并支持 WebGL;
- 微信小程序全部分包限制放宽到 20M,足够使用。
Skia 裁剪
Skia 是 Google 开源的 2D 渲染库,凭借良好的跨设备能力,优秀的性能表现,在 Google 多个产品中被使用,包括 Chrome / Flutter / Android / Fuchsia 都有 Skia 的身影。
Skia 屏蔽了不同设备、平台的具体实现,对外统一以标准的 RenderObject、RenderCommand 开放。
Skia 其中一个 Render Target 是 WebGL,也就是 CanvasKit。
然而 Flutter Web 默认使用的 CanvasKit 足有 6M 之大,即使使用 Brotli 压缩后仍然不符合小程序分包要求。
我们可以通过指定编译选项的方式裁剪 CanvasKit 尺寸,以下是 MPFlutter 使用的 build 配置:
./modules/canvaskit/compile.sh release no_skottie no_sksl_trace no_alias_font no_effects_deserialization no_encode_jpeg no_encode_png no_encode_webp legacy_draw_vertices no_embedded_font no_woff2
从配置可见,我们去掉了 skottie、image encoder、内置字体等不必要的功能,这些功能我们可以使用微信小程序 API 补充回来。
Brotli 压缩后的 wasm 文件刚好符合 2M 分包要求。
CanvasKit 加载
Skia 构建完成后,会得到两个产物,canvaskit.wasm
和 canvaskit.js
。
canvaskit.js
暴露了 wasm 中的各个 c++ 方法调用,同时也提供加载 wasm 的脚手架。
但是 canvaskit.js
的实现默认是 Web 的,我们需要将其中的 fetch
以及 WebAssembly
替换为微信小程序对应的实现。
这里提供一个使用 Skia 绘制红色矩形的微信小程序工程,有兴趣的同学可以下载到本地研究。
mpflutter.feishu.cn/wiki/LWhrw3…
Flutter Web 在微信中运行
要使 Flutter Web 在微信中运行,最大难点在于 Flutter Web 要求的 Web API 如何补充完整。
特别是 Document 、Window、Navigator 这些类,这些类我已经在 GitHub 上开源了,感兴趣的可以逐个文件阅读。
这里举一个 window 的文件节选段落讲解:
export class FlutterMiniProgramMockWindow {
// screens
get devicePixelRatio() {
return wxSystemInfo.pixelRatio;
}
get innerWidth() {
return wxSystemInfo.windowWidth;
}
get innerHeight() {
return wxSystemInfo.windowHeight;
}
// webs
navigator = {
appVersion: "",
platform: "",
userAgent: "",
vendor: "",
language: "zh",
};
// 还有更多。。。
}
Flutter Web 在运行过程中,会通过 window.innerWidth
/ window.innerHeight
获取当前窗口宽高,以便下一步创建合适大小的画布用于渲染。
在微信小程序中,我们需要使用 wx.getSystemInfoSync()
获取对应宽高,并在 MockWindow 中返回给 Flutter。
关于 BOM 的文件,就不详细展开,都是一些胶水代码。
而 Flutter 的 main.dart.js 也需要有一些改造才可以跑在小程序上,主要的改造是通过 export main.dart.js 中的 main 函数,使其适配 CommonJS 可暴露给 Page 调用。
字体的加载
CanvasKit 最大的问题在于字体加载,目前来看是无法复用系统本身的字体的。
我们的做法是通过裁剪 NotoSansSC 字体,只包含常用的 9000+ 汉字,内置于小程序包中优先加载它。
这样有一个好处,小程序不需要强制从 gstatic 下载字体,省流省加载时间。
后续,我们还会研究通过 Canvas 2D 的方式,从本地加载字体。
分包
关于分包,其实是最好做的,因为 Flutter Web 本身就有 defered load 编译能力。
开发者可以轻松地将 main.dart.js 切分成若干个 JS 文件,我们做的就是在 Flutter Web 编译完成后,智能地将这些 JS 文件分配到不同的分包就好了。
资源分包也同理,资源通过 brotli 压缩也可以减少包体积。
总结
整整一套下来,Flutter 已经可以在微信小程序里跑起来了,我们来总结一下做了什么?
我们通过裁剪 Skia 使得 CanvasKit 可以很好地跑在小程序上,通过 BOM 兼容的方法,使得 Flutter Web 可以在微信小程序中找到对应实现,通过字体内置、智能分包的方式很好地解决了微信包体积限制。
该方案目前已经完全跑通,并已可用,同学们可以在 v2.mpflutter.com 文档站了解到更多用法。
如果对方案有任何疑问,也欢迎添加微信交流,感谢大家的关注。
来源:juejin.cn/post/7324923422295670834
轻量桌面应用新星:Electrico,能否颠覆Electron的地位?
在桌面应用开发的世界里,Electron曾经是一位风云人物。它让开发者可以用熟悉的Web技术构建跨平台应用,但它的重量级体积和系统资源的高消耗一直让人头疼。现在,一个新工具悄然登场,试图解决这些问题——Electrico,一个轻量版的桌面应用开发框架。
10MB取代数百MB,你不心动?
你有没有想过,是否能用更轻量的方式开发出与Electron相同功能的桌面应用?毕竟,虽然Electron确实强大,但它那几百MB的安装包和资源消耗对许多小型项目来说太过头了。如果你对这些问题感到无奈,Electrico或许是你一直在等待的解决方案。它的安装包仅仅10MB左右,去掉了庞大的Node.js和Chromium,但依然能给你带来熟悉的开发体验。
什么是Electrico?
Electrico是一个基于Rust的轻量化桌面应用开发框架,完全省去了Node.js和Chrome内核的依赖。Rust编写的Wry库替代了Electron的核心,利用系统自带的WebView组件,保持跨平台兼容性。同时,Electrico还能与操作系统直接交互,提升了运行效率。未来可期的好处是 API 完全贴近 electron,这可能对原 electron 开发者会比较友好。
这一切听起来可能有点像技术术语,但如果你想象一下:Electron是一个庞大的精装房,而Electrico则是一间简单却功能齐全的小公寓。虽然面积小,但该有的功能一点也不少。
三大亮点:为什么Electrico值得关注?
- 1. 极致轻量化:从几百MB到10MB的飞跃 Electron的打包体积问题一直是开发者头疼的地方,尤其是当你只需要开发一个简单的工具时,最终却要交付一个几百MB的安装包。而Electrico的体积仅10MB左右,这样极致的轻量化使得它尤其适合资源有限的应用场景,如内部工具或简单的桌面应用。
- 2. 性能提升:用Rust打造高效体验 Rust作为新兴的系统编程语言,因其安全性和性能闻名。Electrico选择了Rust作为核心,这不仅使得应用更加高效,还让内存管理更加安全。尤其是在需要高性能、低延迟的场景下,Electrico展现了其独特的优势。与Electron依赖的V8引擎和Chromium相比,Electrico能够更直接地与系统交互,减少了许多不必要的资源消耗。
- 1. 兼容性好:熟悉的开发体验 开发者的最大顾虑之一,通常是新工具是否需要重新学习。而Electrico则保留了许多Electron的API设计,比如窗口管理和文件系统访问等。这意味着,习惯Electron的开发者几乎不需要额外学习,就能快速上手。同时,Electrico支持现代浏览器的开发者工具,前后端的调试体验也非常流畅。
实际开发中的表现
为了帮助开发者更快上手,Electrico提供了一个开源示例项目,让你可以直接体验它的运行效果。这个项目采用了Codex,一个轻量级的笔记应用。通过简单的配置和打包,你可以将Codex运行在Electrico上,而最终生成的应用包体积比起Electron版本要小得多。虽然目前Electrico只实现了部分Electron API,但它已经足够应对大多数日常应用场景。
比如,如果你开发的是一个简单的笔记工具、待办事项管理应用,或是一个内部的管理面板,Electrico都能帮你快速构建出符合需求的桌面应用。没有繁琐的依赖管理,也没有巨大的安装包拖慢你的用户体验。
对比Electron:未来的发展趋势
不得不承认,Electron凭借其强大的生态和广泛的支持,依然在桌面应用开发领域占有重要地位。尤其是对于那些需要集成大量第三方库、复杂业务逻辑的应用,Electron仍然是首选。但Electrico的出现,标志着开发者可以在不同场景下有更多选择。
对于那些不需要复杂依赖、注重性能和体积的小型应用,Electrico无疑是一个更现代、更轻便的选择。它展示了桌面应用开发的新趋势——极致轻量化和性能至上,正是未来开发工具追求的方向。
绝对值得一试的新选择
如果你正在寻找一种比Electron更轻量、更高效的解决方案,Electrico无疑值得一试。特别是当你对现有工具的体积和性能表现不满时,Electrico能够带来焕然一新的体验。最重要的是,它的学习成本几乎为零,你可以很快将现有的Electron项目迁移到Electrico上,享受同样的开发便利,却不再担心过大的应用包和资源消耗。
试想一下,你的下一个桌面应用项目,是否可以用更轻、更快、更高效的Electrico来实现?
来源:juejin.cn/post/7415663559310606363
中年码农,裸转AI,是条死路!
有粉丝向我请教这个问题,我觉得有点普遍性,所以我写篇文章。
具体写我的观点之前,为了求生欲,我先说明一下:下面的是我是一家之言,有可能不正确还有偏见,您要是不同意,您也可以留言发表您的看法。
d但是请理性讨论,不要情绪化骂人发泄,不要凡是不同意您观点的人通通都要被喷。感谢。
现在AI火了,就有粉丝问我,自己在某个领域积累了很久,但是呢,以后可能都是做AI的了,那对方可以不可以放弃掉之前领域的积累,全身心投入到AI的学习中去,然后换个赛道呢?
我个人觉得这是条死路。
除非,你已经财富自由了,你可以不用为家里的房贷车贷孩子等等花钱顾虑,你可以未来很长一段时间都不需要担心钱的问题,并且你还有很多精力。
或者你是一个天才,你可以很快的就学习一个新的领域,并且很容易成为新领域里面的大拿。
如果这两个都不成立,那么就是条死路。
道理也非常简单,一旦你是需要通过你的技能来卖钱,从而让自己生存和生活的,那么你现在的技能,和基于现在的技能给你赚钱这个事情本身就很重要。
你想进入一个新的领域,比如AI,你是个普通人,正常智商,需要很多时间学习,你在新的领域很难和其他人,尤其是在新领域里面挣扎奋斗了很多年的人比。
所以,要么你继续指望靠现有的技能赚钱,但是你不会有足够的时间让自己成为AI的专家,要么你现在的技能和工作被打折扣,因为你没办法兼顾现在的和将来的事情,能力时间精力都不行。
所以,我觉得想转,最实际的办法,就是现在的组里有LLM的需求,你可以在现有基础上,做一点相关的,试试水。这种做法会比较丝滑。
如果完全不存在这种机会的,那我只建议两种人转:足够聪明的,学习能力特别强特别快的,和有很多钱,不担心失业以后自己活不下去的。
大部分人来说,尤其是中年码农来说,能够稳固基本盘,就可以了,就是不错的选择了。
毕竟码农的人生,是很容易就走下坡路的,大家也应该做好走下坡路的准备。
我可能真的无法改变你的想法,但我真心希望,你做每一个决定的时候,投入每一份精力的时候,尤其是义无反顾投入大手笔的时候,一定要考虑清楚自己的现实情况,和自己到底有什么样的能力。
所以,有些时候,现实就是这样的残酷。
作者:飞总聊IT
来源:mp.weixin.qq.com/s/fcNyIXgwVNenu_cz0pbCSg
收起阅读 »Flutter UI组件库(JUI)
Flutter UI组件库 (JUI) 介绍
您是否正在寻找一种方法来简化Flutter开发过程,并创建美观、一致的用户界面?您的搜索到此为止!我们的Flutter UI组件库(JUI)提供了广泛的预构建、可自定义组件,帮助您快速构建令人惊叹的应用程序。
快速链接
- Pub包地址:pub.dev/packages/ju…
- GitHub仓库:github.com/ThinkerJack…
- 在线文档:http://www.yuque.com/jui_flutter…
为什么选择我们的UI组件库?
- 丰富的组件集合:从基本按钮到复杂表单,我们的库涵盖了所有UI需求。
- 可定制且灵活:每个组件都高度可定制,让您保持应用程序的独特外观和感觉。
- 易于使用:清晰的文档和直观的API,让您轻松将我们的组件集成到您的项目中。
- 节省时间:减少UI实现的时间,将更多精力放在应用程序的核心功能上。
- 一致的设计:通过我们精心设计的组件,确保整个应用程序的外观协调一致。
组件详解
我们的库包含多种组件,每个组件都经过精心设计,以满足不同的UI需求。以下是对各类组件的详细介绍:
1. 通用组件
1.1 JuiButton(多样化按钮)
JuiButton提供了多种样式和尺寸的按钮选择:
- 多种颜色类型:包括蓝色、灰色、红色等,适应不同的UI主题。
- 可选尺寸:从小型到大型,满足各种布局需求。
- 自定义功能:支持添加图标、调整字体大小、设置点击事件等。
1.2 JuiDashedBorder(虚线边框)
JuiDashedBorder为容器提供了引人注目的虚线边框设计:
- 可自定义虚线样式:调整虚线的宽度、高度、间距等。
- 支持圆角:可设置边框的圆角半径,增加设计的灵活性。
- 互动功能:可添加点击事件,增强用户交互体验。
2. 数据展示
2.1 JuiExpandableText(可展开文本)
JuiExpandableText适用于管理长文本内容:
- 自动折叠:超过指定行数的文本会自动折叠。
- 展开/收起功能:用户可以通过点击展开或收起全文。
- 自定义样式:支持设置文本样式、展开/收起按钮样式等。
2.2 JuiHighlightedText(高亮文本)
JuiHighlightedText用于在文本中突出显示特定内容:
- 灵活的高亮方式:支持多个高亮词,每个词可有不同的样式。
- 可点击功能:高亮部分可设置点击事件,增加交互性。
- 样式自定义:可单独设置普通文本和高亮文本的样式。
2.3 JuiTag(可自定义标签)
JuiTag提供了丰富的标签设计选项:
- 多种颜色和形状:包括圆角矩形、圆形等,颜色可自定义。
- 支持图标:可在标签中添加图标,增强视觉效果。
- 大小可调:适应不同的布局需求。
2.4 JuiNoContent(空状态页面)
JuiNoContent用于优雅地展示无内容状态:
- 预设样式:提供多种常见的空状态设计。
- 自定义能力:支持自定义图片、文字和布局。
- 响应式设计:自适应不同屏幕尺寸。
3. 数据录入
3.1 JuiCheckBox(复选框)
JuiCheckBox提供了灵活的多选功能:
- 多种样式:支持方形和圆形两种基本样式。
- 状态管理:轻松处理选中、未选中和禁用状态。
- 自定义外观:可调整大小、颜色等视觉属性。
3.2 JuiSelectPicker(选择器)
JuiSelectPicker提供了多种类型的选择器:
- 滚轮选择器:适合选择日期、时间等连续数据。
- 列表选择器:适用于长列表项的选择。
- 操作选择器:类似于底部弹出的操作表,适合少量选项的快速选择。
- 支持单选和多选:灵活满足不同的选择需求。
- 自定义选项样式:可自定义选项的外观和布局。
3.3 CustomTimePicker(时间选择器)
CustomTimePicker提供了全面的时间选择功能:
- 多种时间格式:支持年月日、年月、年月日时分等多种格式。
- 范围选择:支持选择时间范围。
- 灵活配置:可设置最小和最大可选时间。
- 自定义外观:可调整选择器的样式以匹配您的应用主题。
4. 反馈
4.1 JuiDialog(对话框)
JuiDialog提供了丰富的对话框选项:
- 标准对话框:用于显示信息和确认操作。
- 输入对话框:允许用户在对话框中输入文本。
- 自定义对话框:支持完全自定义对话框内容。
- 灵活的按钮配置:可自定义确认和取消按钮的文本和行为。
- 样式定制:可调整对话框的宽度、标题样式等。
5. 表单
我们的表单组件集提供了全面的解决方案:
5.1 JuiCustomItem(自定义表单项)
- 允许完全自定义表单项的内容和布局。
5.2 JuiTextDetailItem(文本详情项)
- 用于展示只读的文本信息,适合详情页面。
5.3 JuiTapItem(可点击项)
- 创建可点击的表单项,通常用于导航或触发操作。
5.4 JuiRangeItem(范围选择项)
- 允许用户输入或选择一个数值范围。
5.5 JuiTextInputItem(文本输入项)
- 提供各种文本输入选项,支持单行、多行、数字等输入类型。
所有表单项都支持:
- 必填标记
- 禁用状态
- 自定义样式
- 错误提示
- 辅助说明文本
快速开始
集成我们的组件非常简单。首先,在您的pubspec.yaml
文件中添加依赖:
dependencies:
jui: ^latest_version
然后,在您的代码中导入并使用组件。例如:
import 'package:jui/jui.dart';
// 在您的widget构建方法中
JuiButton(
colorType: JuiButtonColorType.blue,
sizeType: JuiButtonSizeType.large,
text: "开始使用",
onTap: () {
// 您的操作代码
},
)
文档
我们为每个组件提供全面的文档,包括:
- 详细的参数描述
- 代码示例
- 使用最佳实践
我们的在线文档始终保持最新,您可以在这里访问:http://www.yuque.com/jui_flutter…
立即开始构建更好的UI!
不要让UI开发拖慢您的脚步。使用我们的Flutter UI组件库,您可以比以往更快地创建专业外观的应用程序。在您的下一个项目中尝试一下,体验不同!
准备好提升您的Flutter开发了吗?今天就开始使用我们的UI组件库吧!
如果您有任何问题或建议,欢迎在我们的 GitHub 仓库 上提出 issue 或贡献代码。我们期待您的反馈,共同改进这个组件库!
来源:juejin.cn/post/7425814107444740150
花式封装:Kotlin+协程+Flow+Retrofit+OkHttp +Repository,倾囊相授,彻底减少模版代码进阶之路
前言 :众里寻它千百度, 蓦然回首,此种代码却在灯火阑珊处。
一、前言
- 本文介绍思路:
本文重点介绍思路:四种方式花式解决Repository
中模版式的代码,逐级递增
1.1 :涉及到Kotlin
、协程
、Flow、viewModel、Retrofit、Okhttp
相关用法
1.2 :涉及到注解
、反射
、泛型
、注解处理器
相关用法
1.3 :涉及到动态代理
,kotlin
中suspend
方法反射调用及反射中异常处理
1.4 :本示例4个项目如图: - 网络框架搭建的封装,到目前为止最为流行又很优雅的的是
Kotlin
+协程
+Flow
+Retrofit
+OkHttp
+Repository
- 先来看看中间各个类的职责:
- 从上图可以看出
单一职责:
NetApi:
负责网络接口配置,包括 请求地址,请求头,请求方式,参数等等所有配置Flow+Retrofit+Okhttp:
联合起来负责把NetApi
中的各种配置组装成网络请求行为,并且通过Flow 组装成流,通过它可以控制该行为的异步方式,异步开始结束等等一系列的流行为。Repository:
负责Flow+Retrofit+Okhttp
请求结果的数据流,进行加工处理成我们想要的数据,大多数不需要处理的,可以直接给到ViewModel
ViewModel:
负责调用Repository
,拿到想要的数据然后提供给UI方展示使用或者相关使用也可以看到 它的 持有链 从右向左 一条线性持有:
ViewModel
持有Repository
,Repository
持有Flow+Retrofit+Okhttp
,Flow+Retrofit+Okhttp
持有NetApi
- 最终我们可以得到:
5.1. 网络请求行为 会根据NetApi
写出模板式的代码,这块解决模版式的代码在Retrofit
中它通过动态代理,把所有模版式的代码统一成了一个
5.2. 同理:Repository
也是根据NetApi
配置的接口,写成模版式的代码转换成流
二、花式封装(一)
NetApi
的配置:
interface NetApi {
// 示例get 请求
@GET("https://www.wanandroid.com/article/list/0/json")
suspend fun getHomeList(): CommonResult
// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") a: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList(@Path("path") page: Int, @Path("path") f: Float): CommonResult
// 示例get 请求2
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList2222(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList3333(@Path("path") page: Int): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList5555(@Path("path") page: Int, @Query("d") ss: String, @HeaderMap map: Map): CommonResult
@GET("https://www.wanandroid.com/article/list/{path}/json")
suspend fun getHomeList6666(
@Path("path") page: Int,
@Query("d") float: Float,
@Query("d") long: Long,
@Query("d") double: Double,
@Query("d") byte: Byte,
@Query("d") short: Short,
@Query("d") char: Char,
@Query("d") boolean: Boolean,
@Query("d") string: String,
@Body body: RequestBodyWrapper
): CommonResult
//示例post 请求
@FormUrlEncoded
@POST("https://www.wanandroid.com/user/register")
suspend fun register(
@Field("username") username: String,
@Field("password") password: String,
@Field("repassword") repassword: String
): String
/************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded") //todo 固定 header
@POST("https://xxxxxxx")
suspend fun post1(@Body body: RequestBody): String
// @FormUrlEncoded
@Headers("Content-Type: application/x-www-form-urlencoded")
@POST("https://xxxxxxx22222")
suspend fun post12(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
suspend fun post1222(@Body body: RequestBody, @HeaderMap map: Map): String //todo HeaderMap 多个请求头部自己填写
}
2. NetRepository
中是 根据 NetApi
写出下面类似的全模版式的代码:都是返回 Flow
流
class NetRepository private constructor() {
val service by lazy { RetrofitUtils.instance.create(NetApi::class.java) }
companion object {
val instance by lazy { NetRepository() }
}
// 示例get 请求
fun getHomeList() = flow { emit(service.getHomeList()) }
// 示例get 请求2
fun getHomeList(page: Int) = flow { emit(service.getHomeList(page)) }
fun getHomeList(page: Int, a: Int) = flow { emit(service.getHomeList(page, a)) }
fun getHomeList(page: Int, f: Float) = flow { emit(service.getHomeList(page, f)) }
// 示例get 请求2
fun getHomeList2222(page: Int) = flow { emit(service.getHomeList2222(page)) }
fun getHomeList3333(page: Int) = flow { emit(service.getHomeList3333(page)) }
fun getHomeList5555(page: Int, ss: String, map: Map<String, String>) = flow { emit(service.getHomeList5555(page, ss, map)) }
fun getHomeList6666(
page: Int, float: Float, long: Long, double: Double, byte: Byte,
short: Short, char: Char, boolean: Boolean, string: String, body: RequestBodyWrapper
) = flow {
emit(service.getHomeList6666(page, float, long, double, byte, short, char, boolean, string, body))
}
fun register(username: String, password: String, repassword: String) = flow { emit(service.register(username, password, repassword)) }
//
// /************************* 以下只 示例写法,接口调不通,因为找不到那么多 公开接口 全是 Retrofit的用法 来测试 *****************************************************/
//
//
fun post1(body: RequestBody) = flow { emit(service.post1(body)) }
fun post12(body: RequestBody, map: Map<String, String>) = flow { emit(service.post12(body, map)) }
fun post1222(id: Long, asr: String) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
emit(service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader))
}
}
3. viewModel
调用端:
class MainViewModel : BaseViewModel() {
private val repository by lazy { NetRepository.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "one 111 ${it.data?.datas!![0].title}")
}
}
}
}
—————————————————我是分割线君—————————————————
上面花式玩法(一):
此种写法被广泛称作最优雅的一套网络封装
框架,绝大多数中、大厂
基本也就封装到此为止了可能还有些人想着:你的
repository
中就返回了Flow
, 里面就全是简单的emit(xxx)
,我项目里面不是这样的,我的还封装了成功,失败,或者其他的,但总体还是全是模版式的,除了特殊的一些方法,需要在请求前 ,请求后做些处理,有规律有模版的还是占大多数吧,只要大多数都一样的规律模版,都是可以处理的,里面稍微修改下细节,思路都是一样的。哪还能有什么玩法?
可能会有人想到 借助
Hilt
,Dagger2
,Koin
来创建Retrofit
,和创建repository
,创建ViewModel
这里不是讨论依赖注入创建对象的事情哪还有什么玩法?
有,必须有的。
三、花式封装(二)
- 既然上面是
Repository
类中,所有写法都是固定模版式的代码,那么让其根据NetApi:
自动生成Repository
类,我们这里借用注解处理器。 - 具体怎么使用介绍,请参考:
注解处理器在架构,框架中实战应用:MVVM中数据源提供Repository类的自动生成 - 本项目中只需要编译
app_wx2
工程 - 在下图中找到
5. viewModel调用端
class MainViewModel : BaseViewModel() {
private val repository by lazy { RNetApiRepository() }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "two 222 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
6. 如果 Repository
中某个接口方法需要特殊处理怎么办?比如下图,请求前处理一下,从 拿到数据后我需要再次转化处理之后再给到 viewModel
怎么办?
//我这个接口 ,请求前需要 判断处理一下,拿到数据后也需要再处理一下
fun post333(id: Long, asr: String, m: String, n: String, list: List<String>) = flow {
val map = mutableMapOf()
map["id"] = id
map["asr"] = asr
val mapHeader = HashMap()
mapHeader["v"] = 1000
mapHeader["device_sn"] = "Avidfasfa1213"
//接口调用前 根据 需要处理操作
list.forEach {
if (map.containsKey(id.toString())) {
///
}
}
val result = service.post1222(RequestBodyWrapper(Gson().toJson(map)), mapHeader)
// 拿到数据后需要处理操作
val result1 = result
emit(result1)
}.map {
//需要再转化一下
it
}.filter {
//过滤一下
it.length == 3
}
7. 可以在 接口 NetApi
中该方法上配置 @Filter
注解过滤 ,该方法需要自己特殊处理,不自动生成,如下
@Filter
@POST("https://xxxxxxx22222")
suspend fun post333(@Body body: RequestBody, @HeaderMap map: Map): String
- 如果想 post请求的
RequestBody
内部参数单独出来进入方法传参,可以加上 在NetApi
中方法加上@PostBody
:如下:
@PostBody("{"ID":"Long","name":"String"}")
@POST("https://www.wanandroid.com/user/register")
suspend fun testPostBody222(@Body body: RequestBody): String
这样 该方法生成出来的对应方法就是:
public suspend fun testPostBody222(ID: Long, name: java.lang.String): Flow =
kotlinx.coroutines.flow.flow {
val map = mutableMapOf()
map["ID"] = ID
map["name"] = name
val result = service.testPostBody222(com.wx.test.api.retrofit.RequestBodyCreate.toBody(com.google.gson.Gson().toJson(map)))
emit(result)
}
怎么特殊处理,单独手动建一个Repository,针对该方法,单独写,特殊就要特殊手动处理,但是大多数模版式的代码,都可以让其自动生成。
—————————————————我是分割线君—————————————————
到了这里,我们再想, NetApi
是一个接口类,
但是实际上没有写接口实现类啊, 它怎么实现的呢?
我们上面 花式玩法(二)
中虽然是自动生成的,但是还是有方法体,
可不可以再省略点?
可以,必须有!
四、花式玩法(三)
- 我们可以根据
NetApi
里面的配置,自动生成INetApiRepository
接口类, 接口名和参数 都和NetApi
保持一致,唯一区别就是返回的对象变成了Flow
了,
这样在Repository
中就把数据转变为flow
流了 - 配置让代码自动生成的类:
@AutoCreateRepositoryInterface(interfaceApi = "com.wx.test.api.net.NetApi")
class KaptInterface {
}
生成的接口类 INetApiRepository
代码如下:
public interface INetApiRepository {
public fun getHomeList(): Flow>
public fun getHomeList(page: Int): Flow>
public fun getHomeList(page: Int, f: Float): Flow>
public fun getHomeList(page: Int, a: Int): Flow>
public fun getHomeList2222(page: Int): Flow>
public fun getHomeList3333(page: Int): Flow>
public fun getHomeList5555(
page: Int,
ss: String,
map: Map<String, String>
): Flow>
public fun getHomeList6666(
page: Int,
float: Float,
long: Long,
double: Double,
byte: Byte,
short: Short,
char: Char,
boolean: Boolean,
string: String,
body: RequestBodyWrapper
): Flow>
public fun getHomeListA(page: Int): Flow>
public fun getHomeListB(page: Int): Flow
public fun post1(body: RequestBody): Flow
public fun post12(body: RequestBody, map: Map<String, String>): Flow
public fun post1222(body: RequestBody, map: Map<String, Any>): Flow
public fun register(
username: String,
password: String,
repassword: String
): Flow
public fun testPostBody222(ID: Long, name: java.lang.String): Flow
}
Repository
职责承担的调用端:用动态代理:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {
val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }
companion object {
val instance by lazy { RepositoryPoxy() }
}
fun callApiMethod(serviceR: Class<R>): R {
return Proxy.newProxyInstance(serviceR.classLoader, arrayOf(serviceR)) { proxy, method, args ->
flow {
val funcds = findSuspendMethod(service, method.name, args)
if (args == null) {
emit(funcds?.callSuspend(api))
} else {
emit(funcds?.callSuspend(api, *args))
}
// emit((service.getMethod(method.name, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call).execute().body())
}.catch {
if (it is InvocationTargetException) {
throw Throwable(it.targetException)
} else {
it.printStackTrace()
throw it
}
}
} as R
}
}
BaseRepositoryProxy
中内容:
open class BaseRepositoryProxy {
private val map by lazy { mutableMapOf?>() }
private val sb by lazy { StringBuffer() }
@OptIn(ExperimentalStdlibApi::class)
fun findSuspendMethod(service: Class<T>, methodName: String, args: Array<out Any>): KFunction<*>? {
sb.delete(0, sb.length)
sb.append(service.name)
.append(methodName)
args.forEach {
sb.append(it.javaClass.typeName)
}
val key = sb.toString()
if (!map.containsKey(key)) {
val function = service.kotlin.memberFunctions.find { f ->
var isRight = 0
if (f.name == methodName && f.isSuspend) {
if (args.size == 0 && f.parameters.size == 1) {
isRight = 2
} else {
f.parameters.forEachIndexed { index, it ->
if (index > 0 && args.size > 0) {
if (args.size == 0) {
isRight = 2
return@forEachIndexed
}
if (it.type.javaType.typeName == javaClassTransform(args[index - 1].javaClass).typeName) {
isRight = 2
} else {
isRight = 1
return@forEachIndexed
}
}
}
}
}
//方法名一直 是挂起函数 方法参数个数一致, 参数类型一致
f.name == methodName && f.isSuspend && f.parameters.size - 1 == args.size && isRight == 2
}
map[key] = function
}
return map[key]
}
private fun javaClassTransform(clazz: Class<Any>) = when (clazz.typeName) {
"java.lang.Integer" -> Int::class.java
"java.lang.String" -> String::class.java
"java.lang.Float" -> Float::class.java
"java.lang.Long" -> Long::class.java
"java.lang.Boolean" -> Boolean::class.java
"java.lang.Double" -> Double::class.java
"java.lang.Byte" -> Byte::class.java
"java.lang.Short" -> Short::class.java
"java.lang.Character" -> Char::class.java
"SingletonMap" -> Map::class.java
"LinkedHashMap" -> MutableMap::class.java
"HashMap" -> HashMap::class.java
"Part" -> MultipartBody.Part::class.java
"RequestBody" -> RequestBody::class.java
else -> {
if ("RequestBody" == clazz.superclass.simpleName) {
RequestBody::class.java
} else {
Any::class.java
}
}
}
}
- ViewModel中调用端:
class MainViewModel : BaseViewModel() {
private val repository by lazy { RepositoryPoxy.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiMethod(INetApiRepository::class.java).getHomeList(page).onEach {
android.util.Log.e("MainViewModel", "three 333 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
—————————————————我是分割线君—————————————————
- 上面生成的接口类
INetApiRepository
其实方法和NetApi
拥有相似的模版,唯一区别就是返回类型,一个是对象,一个是Flow 流的对象还能省略吗?
有,必须有
五、花式玩法(四)
- 直接修改
RepositoryPoxy
,作为Reposttory的职责 ,连上面的INetApiRepository
的接口类全部省略了, 如下:
class RepositoryPoxy private constructor() : BaseRepositoryProxy() {
val service = NetApi::class.java
val api by lazy { RetrofitUtils.instance.create(service) }
companion object {
val instance by lazy { RepositoryPoxy() }
}
fun callApiMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val clssss = mutableListOfout Any>>()
args?.forEach {
clssss.add(javaClassTransform(it.javaClass))
}
val parameterTypes = clssss.toTypedArray()
val call = (service.getMethod(methodName, *parameterTypes)?.invoke(api, *(args ?: emptyArray())) as Call)
call?.execute()?.body()?.let {
emit(it as R)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun callApiSuspendMethod(clazzR: Class<R>, methodName: String, vararg args: Any): Flow {
return flow {
val funcds = findSuspendMethod(service, methodName, args)
if (args == null) {
emit(funcds?.callSuspend(api) as R)
} else {
emit(funcds?.callSuspend(api, *args) as R)
}
}
}
}
2. ViewModel中调用入下:
class MainViewModel : BaseViewModel() {
private val repository by lazy { RepositoryPoxy.instance }
fun getHomeList(page: Int) {
flowAsyncWorkOnViewModelScopeLaunch {
val time = System.currentTimeMillis()
repository.callApiSuspendMethod(HomeData::class.java, "getHomeListB", page).onEach {
android.util.Log.e("MainViewModel", "four 444 ${it.data?.datas!![0].title}")
android.util.Log.e("MainViewModel", "耗时:${(System.currentTimeMillis() - time)} ms")
}
}
}
}
六、总结
通过上面4中花式玩法:
- 花式玩法1: 我们知道了最常见最优雅的写法,但是模版式
repository
代码太多,而且需要手动写 - 花式玩法2: 把花式玩法1中的模版式
repository
,让其自动生成,对于特殊的方法,单独手动再写个repository
,这样让大多数模版式代码全自动生成 - 花式玩法3:
NetApi
,可以根据配置,动态代理生成网络请求行为,该行为统一为动态代理实现,无需对接口类NetApi
单独实现,那么我们的repository
也可以 生成一个接口类INetApiRepository
,然后动态代理实现其内部 方法体逻辑 - 花式玩法4:我连花式玩法3中的接口类
INetApiRepository
都不需要了,直接反射搞定所有。 - 同时可以学习到,注解、反射、泛型、注解处理器、动态代理
项目地址
感谢阅读:
欢迎 点赞、收藏、关注
来源:juejin.cn/post/7417847546323042345
Java中使用for而不是forEach遍历List的10大理由
首发公众号:【赵侠客】
引言
我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()
来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()
真的很高大上吗?它真的比传统的for
循环好用吗?本文就列出10大推荐使用for
而不是forEach()
的理由。
理由一、for性能更好
在我的固有认知中我是觉得for
的循环性能比Stream.forEach()
要好的,因为在技术界有一条真理:
越简单越原始的代码往往性能也越好
而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for
的循环性能真的比Stream.forEach()
好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids
分别使用for
和Stream.forEach()
遍历出所有的元素,以下是测试代码:
@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}
@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}
@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}
我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:
方法 | 十 | 百 | 千 | 万 | 10万 |
---|---|---|---|---|---|
forEach | 45194532 | 17187781 | 2501802 | 200292 | 20309 |
for | 127056654 | 19310361 | 2530502 | 202632 | 19228 |
for对比 | ↑181% | ↑12% | ↑1% | ↓1% | ↓5% |
从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。
所以可以得出结论:
在小List(万元素以内)遍历中for性能要优于Stream.forEach
理由二、for占用内存更小
Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G
输出GC日志:
- 使用for遍历
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]
从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s
- 使用stream
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]
从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。
理由三、for更易控制流程
我们使用for遍历List可以很方便的使用break
、continue
、return
来控制循环,而使用Stream.forEach在循环中是不能使用break
、continue
,特别指出的使用return
是无法中断Stream.forEach循环的,如下代码:
List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}
输出:
forEach-1
forEach-2
forEach-3
==
for-1
for-2
从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。
理由四、for访问变量更灵活
这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:
Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}
ids.stream().forEach(i -> {
//报错
sum++;
});
像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:
AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});
所以在访问变量方面for会更加灵活。
理由五、for处理异常更方便
这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:
public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}
ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}
我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。
理由六、for能对集合添加、删除
在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);
输出:
[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException
如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。
理由七、for Debug更友好
Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);
以下是DeBug截图:
我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。
理由八、for代码可读性更好
Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:
- 使用for面向过程编程代码:
public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}
- 使用forEach面向函数式编程代码:
public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}
对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。
理由九、for更好的管理状态
for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:
boolean flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});
这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。
理由十、for可以使用索引直接访问元素
在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());
我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。
总结
本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。
来源:juejin.cn/post/7416848881407524902
VSCode无限画布模式(可能会惊艳到你的一个小功能)
👇 该文章内容的
受众
是VSCode
的用户,不满足条件的同学可以选择性阅读
哈~
❓现存的痛点
VSCode
是我的主力开发工具,在实际的开发中,我经常会对编辑器进行分栏处理,组件A的tsx、css代码、工具类方法各一个窗口
,组件B的tsx、css代码、工具类方法各一个窗口
,组件C的......
当组件拆的足够多
的时候,多个分栏会把本就不大的编辑器窗口分成N份,每一份的可视区域就小的可怜
,切换组件代码时,需要不小的翻找成本
,而且经常忘记我之前把文件放在了那个格子里,特别是下面的场景(一个小窗口内开了N个小tab
),此时更难找到想要的窗口了...
问题汇总
- 分栏会导致每个窗口的
面积变小
,开发体验差(即使可以双击放大,但效果仍不符合预期); - 编辑器窗口
容易被新打开的窗口替换掉
,常找不到之前打开的窗口; - 窗口的可操作性不强,
位置不容易调整
。
💡解题的思路
1. 自由 & 独立的编辑器窗口
分栏会导致每个窗口的面积变小
,开发体验不好。
那就别变小了!
每个编辑器窗口都还是原来的大小,甚至更大!
2. 无限画布
编辑器窗口容易被新打开的窗口替换掉
,常找不到之前打开的窗口。窗口的可操作性不强,位置不容易调整。
那就每个窗口都拥有一个自己的位置
好了!拖一下就可以找到了!
3. 画布体验
好用的画布是可以较大的提升用户体验的,下面重点做了四个方面的优化:
3.1 在编辑器里可以快速缩小 & 移动
因为不可避免的会出现一些
事件冲突
(比如编辑器里的滚动和画布的滚动、缩放等等),通过提供快捷键的
解法,可以在编辑器内快速移动、缩放
画布。command + 鼠标上下滑动 = 缩放
option + 鼠标移动 = 画布移动
注意下图,鼠标还在编辑器窗口中,依然可以拖动画布👇🏻
3.2 快速放大和缩小编辑窗口
通过快捷按钮的方式,可以快速的放大和缩小编辑器窗口。
3.3 一键定位到中心点
不小心把所有窗口都拖到了画布视口外找不到了?没事儿,可以通过点击快捷按钮的方式,快速回到中心点。
3.4 窗口的合并和分解
可以在窗口下进行编辑器的合并,即可以简单的把一些常用的窗口进行合并、分解。
💬 提出的背景
作为一名前端开发同学
,避免不了接触UI同学的设计稿,我司使用的就是figma
,以figma
平台为例,其无限画布模式可以非常方便的平铺N个稿子,并快速的看到所有稿子的全貌、找到自己想要的稿子等等,效果如下:
没错!我就是基于这个思路提出了第一个想法,既然图片可以无限展示,编辑器为什么不能呢?
这个想法其实去年就有了,期间大概断断续续花了半年多左右的时间在调研和阅读VSCode的源码上,年后花了大概3个月的时间进行实现,最终在上个月做到了上面的效果。
经过约一个月的试用(目前我的日常需求均是使用这种模式进行的开发)
,发现效果超出预期
,我经常会在画布中开启约10+
个窗口,并频繁的在各个窗口之间来回移动
,在这个过程中,我发现以下几点很让我很是欣喜:
空间感
:我个人对“空间和方向”比较敏感,恰好画布模式会给我一种真实的空间感
,我仿佛在一间房子里,里面摆满了我的代码,我穿梭在代码中,修一修这个,调一调这个~满足感
:无限画布的方式,相当于我间接拥有了无限大的屏幕,我只需要动动手指找到我的编辑窗口就好了,它可以随意的放大和缩小,所以我可以在屏幕上展示足够多的代码。更方便的看源码
:我可以把源码的每个文件单独开一个窗口,然后把每个窗口按顺序铺起来,摆成一条线,这条线就是源码的思路(当然可以用截图的方式看源码 & 缕思路,但是,需要注意一点,这个编辑器是可以交互的!)
⌨️ 后续的计划
后续计划继续增强画布
的能力,让它可以更好用:
小窗口支持命名
,在缩小画布时,窗口缩小,但是命名不缩小,可以直观的找到想要的窗口。增强看源码的体验
:支持在画布上添加其他元素(文案、箭头、连线),试想一下,以后在看源码时,拥有一个无限的画板来展示代码和思路,关键是代码是可以交互的,这该有多么方便!类似MacOS的台前调度功能
:把有关联的一些窗口分组,画布一侧有分组的入口,点击入口可以切换画布中的组,便于用户快速的进行批量窗口切换,比如A页面的一些JS、CSS等放在一个组,B页面放在另一个组,这样可以快速的切换文件窗口。
📔 其他的补充
调研过程中发现无法使用VSCode的插件功能来实现这个功能,所以只好fork了一份VSCode的开源代码,进行了大量修改,最终需要对源码进行编译打包才能使用(一个新的VSCode),目前只打包了mac的arm64版本来供自己试用。
另外,由于VSCode并不是100%开源(微软的一些服务相关的逻辑是闭源的),所以github上的开源仓库只是它的部分代码,经过编译之后,发现缺失了远程连接相关的功能,其他的功能暂时没有发现缺失。
🦽 可以试用吗
目前还没有对外提供试用版的打算
,想自己继续使用一些时间,持续打磨一下细节,等功能细节更完善了再对外进行推广,至于这次的软文~ 其实是希望可以引起阅读到这里的同学进行讨论,可以聊一下你对该功能的一些看法,以及一些其他的好点子~
,thx~
🫡 小小的致敬
- 致敬VSCode团队,在阅读和改造他们代码的过程中学习到了不少hin有用的代码技能,也正是因为有他们的开源,才能有我的这次折腾👍🏻
- 致敬锤子科技罗永浩老师,这次实现思路也有借鉴当年发布的“无限屏”功能,本文的头图就是来自当年的发布会截图。
来源:juejin.cn/post/7375586227984220169
工作六年,看到这样的代码,内心五味杂陈......
工作六年,看到这样的代码,内心五味杂陈......
那天下午,看到了令我终生难忘的代码,那一刻破防了......
ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......
📖一、历史背景
1.1 数据隔离
预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明: env 字段即环境字段。如下图所示:
1.2 隔离之前
🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;
有一天预发环境的操作影响到客户线上的数据。 为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。
当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。
1.3 隔离改造
其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。
每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:
SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}
1.4 隔离方案
最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!
具体方案:自定义 mybatis 拦截器进行统一处理。 通过这个方案可以解决以下几个问题:
- 业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。
- 挨个添加补充字段,工程量很多,出错概率极高
- 后续扩展容易
1.5 最终落地
在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。 考虑历史数据过渡,将 env = ${当前环境}
修改成 env in (${当前环境},'all')
SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}
具体实现逻辑如下图所示:
- 其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同
- 借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。链接JSQLParser
思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:
@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)
@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重写 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}
return invocation.proceed();
}
}
一气呵成,完美上线。
📚二、发展演变
2.1 业务需求
随着业务发展,出现了以下需求:
- 上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造
SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')
- 有一些环境的数据相互相共享,比如预发和灰度等
- 开发人员的部分后面,希望在预发能纠正线上数据等
2.2 初步沟通
这个需求的落地交给了来了快两年的小鲜肉。 在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......
(●ˇ∀ˇ●)年纪大了需要给年轻人机会。
2.3 勤劳能干
小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)
2.4 具体实现
大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。
大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。
SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}
2.5 错误原因
经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。 简化举例: A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:
2.6 五味杂陈
当我看到代码的一瞬间,彻底破防了......
queryProject 方法里面调用 findProjectWithOutEnv,
在两个方法中,都有填充处理 env 的代码。
2.7 遍地开花
然而,这三行代码,随处可见,在业务代码中遍地开花.......
// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();
// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
//....... 业务代码 ....
// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);
改了个遍,很勤劳👍......
2.8 灵魂开问
难道真的就只能这么做吗,当然还有......
- 开闭原则符合了吗
- 改漏了应该办呢
- 其他人遇到跳过的检查的场景也加这样的代码吗
- 业务代码和功能代码分离了吗
- 填充到应用上下文对象 user 合适吗
- .......
大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......
内心涌动😥,我觉得要重构一下。
📒三、重构一下
3.1 困难之处
在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。 只能通过栈帧查询到调用链。
3.2 问题列表
- 尽量不要修改已有方法,保证不影响原有逻辑;
- 尽量不要在业务方法中修改功能代码;关注点分离;
- 尽量最小改动,修改一处即可实现逻辑;
- 改造后复用能力,而不是依葫芦画瓢地添加这种代码
3.3 实现分析
- 用独立的 ThreadLocal,不与当前用户信息上下文混合使用
- 注解+APO,通过注解参数解析,达到目标功能
- 对于方法之间的调用或者循环调用,要考虑优化
同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。
3.4 使用案例
改造后的使用案例如下,案例说明:project 表在预发环境校验跳过。
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response) {
......
}
在使用的调用入口处添加注解。
3.5 具体实现
- 方法上标记注解, 注解参数定义规则
- 切面读取方法上面的注解规则,并传递到应用上下文
- 拦截器从应用上下文读取规则进行规则判断
注解代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
* @return
*/
boolean isKip() default true;
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipEnvList() default {};
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipTableList() default {};
}
3.6 不足之处
- 整个链路上的这个表操作都会跳过,颗粒度还是比较粗
- 注解只能在入口处使用,公共方法调用尽量避免
🤔那还要不要完善一下,还有什么没有考虑到的点呢? 拿起手机看到快12点的那一刻,我还是选择先回家了......
📝 四、总结思考
4.1 隔离总结
这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。
4.2 编码总结
同样的代码写两次就应该考虑重构了
- 尽量修改一个地方,不要写这种边边角角的代码
- 善用自定义注解,解决这种通用逻辑
- 可以妥协,但是要有底线
- ......
4.3 场景总结
简单梳理,自定义注解 + AOP 的场景
场景 | 详细描述 |
---|---|
分布式锁 | 通过添加自定义注解,让调用方法实现分布式锁 |
合规参数校验 | 结合 ognl 表达式,对特定的合规性入参校验校验 |
接口数据权限 | 对不同的接口,做不一样的权限校验,以及不同的人员身份有不同的校验逻辑 |
路由策略 | 通过不同的注解,转发到不同的 handler |
...... |
自定义注解很灵活,应用场景广泛,可以多多挖掘。
4.4 反思总结
- 如果一开始就做好技术方案或者直接使用不同的数据库
- 是否可以拒绝那个所谓的需求
- 先有设计再有编码,别瞎搞
4.5 最后感想
在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪; 突然一想,这么做的意义又有多大呢?
来源:juejin.cn/post/7294844864020430902
ArrayList扩容原理
ArrayList扩容原理(源码理解)
从源码角度对ArrayList扩容原理进行简介,我们可以更深入地了解其内部实现和工作原理。以下是基于Java标准库中ArrayList扩容原理源码的简介
1、类定义与继承关系
ArrayList在Java中的定义如下:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
ArrayList是一个泛型类,继承自AbstractList并实现了List接口,同时还实现了RandomAccess、Cloneable和Serializable接口。这 些接口分别表示ArrayList支持随机访问、可以被克隆以及可以被序列化。
2、核心成员变量(牢记)
elementData
:是实际存储元素的数组,它可以是默认大小的空数组(当没有指定初始容量且没有添加元素 时),也可以是用户指定的初始容量大小的数组,或者是在扩容后新分配的数组。
size
:表示数组中当前元素的个数。
transient Object[] elementData; //数组
private int size; //元素个数
DEFAULT_CAPACITY
是ArrayList的默认容量,当没有指定初始容量时,会使用这个值。
//默认初始容量。
private static final int DEFAULT_CAPACITY = 10;
EMPTY_ELEMENTDATA
表示空数组。
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
也表示空数组,为了区分而命名不同。
//用于创建空对象的共享空数组实例。
private static final Object[] EMPTY_ELEMENTDATA = {};
//用于默认大小的空数组实例的共享空数组实例。我们将它与EMPTY_ELEMENTDATA区分开来,以便在添加第一个元素时知道要扩容多少。
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
3、构造方法
ArrayList提供了多个构造方法,包括无参构造方法、指定初始容量的构造方法。
java
//无参构造
//构造一个初始容量为10的空数组。
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//有参构造
//构造具有指定初始容量的空数组。
public ArrayList(int initialCapacity) { //构建有参构造方法
if (initialCapacity > 0) { //如果传入参数>0
this.elementData = new Object[initialCapacity]; //创建一个数组,大小为传入的参数
} else if (initialCapacity == 0) { //如果传入的参数=0
this.elementData = EMPTY_ELEMENTDATA; //得到一个空数组
} else { //否则
throw new IllegalArgumentException("Illegal Capacity: "+ //抛出异常
initialCapacity);
}
}
这里可以看到无参构造方法用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
表示空数组,而有参构造方法中当传入参数=0,用的是EMPTY_ELEMENTDATA
表示空数组。
4、扩容机制
具体流程:
1、开始添加元素前先判断当前数组容量是否足够(ensureCapacityInternal()
方法),这里有个特例就是添加第一个元素时要先将数组扩容为初始容量大小(calculateCapacity()
方法)。如果足够就向数组中添加元素。
2、如果当前数组容量不够,开始计算新容量的大小并赋值给新数组,复制原始数组中的元素到新数组中(grow()
方法)
流程图如下:
从向ArrayList添加元素来观察底层源码是如何实现的
观察add()
方法,其中提到一个不认识的ensureCapacityInternal()
方法,把他看做用来判断数组容量是否足够的方法,判断完后将元素添加到数组中
public boolean add(E e) {
ensureCapacityInternal(size + 1); //判断数组容量是否足够,传入的一个大小为(size+1)的参数
elementData[size++] = e; //添加元素
return true;
}
现在来看上面add()
方法提到的ensureCapacityInternal()
方法, 进入查看源码,又出现两个不认识的方法: calculateCapacity()
方法和ensureExplicitCapacity()
方法。
private void ensureCapacityInternal(int minCapacity) { //这里minCapacity大小就是上面传入参数:size+1
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
```
calculateCapacity()
方法:里面有一个判断语句,判断当前数组是不是空数组。如果是空数组那就将数组容量初始化为10,如果不是空数组,那就直接返回minCapacity
。
ensureExplicitCapacity()
方法:重点观察判断语句,将calculateCapacity()
方法中传进来的minCapacity
与原数组的长度作比较,当原数组长度小于minCapacity
的值就开始进行扩容。
// calculateCapacity方法
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//判断数组是否为空
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
//数组为空时比较,DEFAULT_CAPACITY=10,minCapacity=size+1,DEFAULT_CAPACITY一定比minCapacity大,所以空数组容量初始化为10
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
//数组不为空,minCapacity=size+1,相当于不变
return minCapacity;
}
//-------------------------------------------分割线-----------------------------------------------------//
// ensureExplicitCapacity方法
private void ensureExplicitCapacity(int minCapacity) {//这里的minCapacity是上面传过来的
modCount++;
if (minCapacity - elementData.length > 0) //判断数组长度够不够,不够才扩
grow(minCapacity);
}
举例
- 当向数组添加第1个元素时size=0,
calculateCapacity()
方法中判断数组为空,数组容量初始化为10。到了ensureExplicitCapacity()
方法中,因为是空数组,所以elementData.length
=0,判断成立,数组进行扩容大小为10。 - 当向数组添加第2个元素时size=1,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为2。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断不成立,不扩容 - 当向数组添加第11个元素时size=10,
calculateCapacity()
方法中判断数组为非空,为minCapacity赋值为11。到了ensureExplicitCapacity()
方法中,因为数组大小已经扩容为10,所以elementData.length
=10,判断成立,开始扩容
前面都是判断数组要不要进行扩容,下面内容就是如何扩容。
首先,grow()
方法是扩容的入口,它根据当前容量计算新容量,并调用Arrays.copyOf方法复制数组。hugeCapacity()
方法用于处理超大容量的情况,确保不会超出数组的最大限制。
* 这一步是为了先确定扩容的大小,再将元素复制到新数组中
private void grow(int minCapacity) {
int oldCapacity = elementData.length; //定义一个oldCapacity接收当前数组长度
int newCapacity = oldCapacity + (oldCapacity >> 1); //定义一个newCapacity接收oldCapacity1.5倍的长度
if (newCapacity - minCapacity < 0) //如果newCapacity长度<minCapacity
newCapacity = minCapacity; //将minCapacity赋值给newCapacity
if (newCapacity - MAX_ARRAY_SIZE > 0) //如果newCapacity长度>最大的数组长度
newCapacity = hugeCapacity(minCapacity); //将进行hugeCapacity方法以后的值赋值给newCapacity
elementData = Arrays.copyOf(elementData, newCapacity);//开始扩容
}
查看hugeCapacity()
方法 (防止扩容后的数组太大了)
MAX_ARRAY_SIZE 理解为:快接近integer的最大值了。
Integer.MAX_VALUE 理解为:integer的最大值。
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) //如果minCapacity<0
throw new OutOfMemoryError(); //抛出异常
return (minCapacity > MAX_ARRAY_SIZE) ? //返回一个值,判断minCapacity是否大于MAX_ARRAY_SIZE
Integer.MAX_VALUE : //大于就返回 Integer.MAX_VALUE
MAX_ARRAY_SIZE; //小于就返回 MAX_ARRAY_SIZE
}
```
最后一步,了解是如何如何将元素添加到新数组的
查看Arrays.copyof
源代码
用于将一个原始数组(original)复制到一个新的数组中,新数组的长度(newLength)可以与原始数组不同。
public static <T> T[] copyOf(T[] original, int newLength) {
return (T[]) copyOf(original, newLength, original.getClass());
}
查看copyof()
方法 (判断新数组与原数组类型是否一致)
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
开始复制原数组的元素到新数组中
将一个数组`src`的从索引`srcPos`开始的`length`个元素复制到另一个数组`dest`的从索引`destPos`开始的位置。
public static native void arraycopy(Object src, int srcPos,
Object dest, int destPos,
int length);
参数说明:
参数说明:
src
:原数组,类型为Object,表示可以接受任何类型的数组。srcPos
:原数组的起始索引,即从哪个位置开始复制元素。dest
:新数组,类型为Object,表示可以接受任何类型的数组。destPos
:新数组的起始索引,即从哪个位置开始粘贴元素。length
:要复制的元素数量。
从宏观上来说,ArrayList展现的是一种动态数组的扩容,当数组中元素个数到达一定值时数组自动会扩大容量,以方便元素的存放。
从微观上来说,ArrayList是在当数组中元素到达一定值时,去创建一个大小为原数组1.5倍容量的新数组,将原数组的元素复制到新数组当中,抛弃原数组。
来源:juejin.cn/post/7426280686695710730
工作7年了,才明白技术的本质不过是工具而已,那么未来的方向在哪里?
前言
Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。
五一过去了,不知道大家有没有好好的放松自己呢?愉快的假期总是这么短暂,打工人重新回到自己的岗位。
我目前工作7年了,这几年来埋头苦干,学习了很多技术,做了不少系统,也解决过不少线上问题。自己虽然在探寻个人IP与副业,自己花了很多时间去思考技术之外的路该怎么走。但转念一想,我宁愿花这么多时间去探索技术之外的路线,但是却从没好好静下来想一下技术本身。
技术到底是什么,你我所处的技术行业为什么会存在,未来的机会在哪里。
因此,我结合自己的工作经历,希望和大家一起聊聊,技术的本质与未来的方向,到底在哪里,才疏学浅,如果内容有误还希望你在评论区指正。
背景
行业现状
互联网行业发展放缓,进入调整阶段,具体表现为市场需求、用户规模、营收利润、创新活力等方面的放缓或下降。
一些曾经风光无限的互联网公司也遭遇了业绩下滑、股价暴跌、裁员潮等困境,你是不是也曾听过互联网的寒冬已至的言论?
其实互联网本身,并没有衰败或消亡,而是因为互联网高速发展的时代过去了。
- 中国经济增速放缓、消费升级趋势减弱、人口红利消失等因素的影响,中国互联网市场的需求增长趋于饱和或下降。
- 用户规模停滞,智能手机普及率饱和,互联网用户规模增长趋于停滞,由增量市场变为存量市场,互联网获客成本越来越高。
- 监管政策收紧,互联网行业规范和监管愈加严格,更加注重合规,因此互联网行业也会收到影响。
供需环境
供需环境变化,应届生要求越来越高,更加注重学历。
社招更是看中学历的同时,开始限制年龄。招聘更看重项目经验,业务经验。五年前,你只要做过一些项目,哪怕不是实际使用的,也很容易拿到offer。而现在企业在看中技术能力的同时,还会关注候选人对与行业的理解,以及以往的工作经验。
技术的本质
先说结论,技术的本质是工具。 我把过去几年的认知变化分成了四个阶段,给大家展示一下我对于技术的认知成长过程。
第一阶段
技术就是应用各类前沿的框架、中间件。
刚毕业时,我就职于一家传统信息企业。谈不上所谓的架构,只需要Spring、Mysql就构建起了我们的所有技术栈。当然,微服务框架更不可能,Redis、MQ在系统中都没使用到。
此时互联网企业已经开始快速发展,抖音诞生区区不过一年。
一线城市的互联网公司,都已经开始使用上了SpringBoot、微服务,还有各类我没有听说过的中间件。
工作环境的闭塞,让我对各类技术有着无限憧憬,因为很多当下难以解决的问题,应用一些新技术、新架构,就能立刻对很多难题降维打击。
举个例子,如果你使用本地缓存,那么集群部署时,你一定要考虑集群的缓存一致性问题,可这个问题如果用上分布式缓存Redis,那么一致性问题迎刃而解。
所以那个时候的我认为,技术就是应用各类中间件,只要用上这些中间件、框架,我就已经走在了技术的前沿。
第二阶段
技术对我而言就是互联网。
半年后,我摆脱传统行业,来到了一个小型互联网公司,用上了不少在我眼中的新技术。
但任何新技术,如果只停留在表面,那么对于使用者来说,就是几个API,几行代码,你很快就会感到厌倦,发现问题也会焦虑,因为不清楚原理,问题就无从排查。
很快,所谓的“新技术”,就不能给我带来成就感了。我开始羡慕那些互联网行业APP,无时无刻都在畅想着,如果我做的产品能够被大家看到并应用,那该是多么有意思的一件事情。
于是我又认为,技术就是做那些被人看见、被人应用的网站、APP。
第三阶段
技术就是高并发、大流量、大数据。
当自己真正负责了某一个APP的后端研发后,很多技术都有机会应用,也能够在AppStore下载自己的APP了,没事刷一刷,看到某一个信息是通过我自己写的代码展示出去,又满足了第二阶段的目标了。
那么我接下来追求的变成了,让更多的人使用我做的产品,起码让我的亲人、朋友也能看到我做的东西。
当然,随之而来的就是日益增长的数据规模和大流量,这些无时无刻都在挑战系统的性能,如何去解决这些问题,成为了我很长一段时间的工作主线。
应对高并发、大流量,我们需要对系统作出各种极致性能的优化。
为了性能优化,还需要了解更多的底层原理,才能在遇到问题时有一个合理的解决方案。
所以,我认为技术就是高并发、大数据,做好这些,才算做好了技术。
第四阶段
经过了传统企业,到互联网公司,再到互联网大厂的一番经历,让我发现技术的本质就是工具,在不同阶段,去解决不同的问题。
在第一阶段,技术解决了各类行业的数据信息化问题,借助各类中间件、架构把具体的需求落地。
在第二阶段、第三阶段,技术解决了业务的规模化问题,因为在互联网,流量迅猛增长,我需要去用技术解决规模化带来的各类的问题,做出性能优化。
当然,技术在其他领域也发挥着作用,比如AI&算法,给予了互联网工具“智能化”的可能,还有比如我们很难接触到的底层框架研发,也就是技术的“技术”,这些底层能力,帮助我们更好的发挥我们的技术能力。
未来机会
大厂仍是最好的选择
即使是在互联网增速放缓、内卷持续严重的今天,即使我选择从大厂离职,但我依然认为大厂是最好的选择。
为什么这么说,几个理由
- 大厂有着更前沿的技术能力,你可以随意选择最适合的工具去解决问题
- 大厂有着更大的数据、流量规模,你所做的工作,天然的就具备规模化的能力
- 大厂有先进的管理方法,你所接触的做事方法、目标管理可能让你疲倦,但工作方法大概率是行业内经过验证的,你不会走弯路,能让你有更快的进步速度
数字化转型
如果你在互联网行业,可能没有听说过这个词,因为在高速发展的互联网行业,本身就是数字驱动的,比如重视数据指标、AB实验等。但在二线、三线城市的计算机行业或者一些传统行业,数字化转型是很大的发展机会。
过去十年,传统行业做的普遍是信息化转型,也就是把线下,需要用纸、笔来完成工作的,转移到系统中。
那什么是数字化转型?
我用我自己的理解说一下,数字化转型就是业务流程精细化管理,数据驱动,实现降本增效。
我目前所在的公司的推进大方向之一,就是数字化转型。因为许多行业的数字化程度非常低,本质而言,就是把数字驱动的能力,带给传统企业,让传统企业也能感受到数字化带来的发展可能。
举个例子,比如一个餐饮系统数字化转型后,一方面可以把用户下单、餐厅接单、开始制作、出餐、上餐线上化,还可以和原材料供应系统打通,当有订单来时,自动检测餐饮的库存信息,库存不足及时提供预警,甚至可以作出订单预测,比如什么时间点,哪类餐品的点单量最高。
当然,数字化转型与互联网有着极大的不同,在互联网行业,你只需要坐在工位,等着产品提出需求就可以了。但是传统行业,你需要深入客户现场,实地查看业务流程,与用户交谈,才能真正的理解客户需求。
或许这样的工作并不炫酷,还需要出差,但在互联网行业饱和的今天,用技术去解决真实世界的问题,也不失为一个很好的选择。
AI&智能化
随着AI快速发展,各类智能化功能已经遍布了我们使用的各类APP,极客时间有了AI自动总结,懂车帝有了智能选车度搜索问题,有时候第一个也会是AI来给我们解答。
任何行业遇上AI都可以再做一遍。
抛开底层算法、模型不谈,但从使用者角度来说,最重要的是如何与行业、场景结合相使用。但是想要做好应用,需要你在行业有着比较深的沉淀,有较深的行业认知。
当然,智能化也不仅限于AI,像上面餐饮系统的例子,如果能够实现订单预测、自动库存管理,其实也是智能化的体现。
终身学习
技术能力
持续精进专业技术能力,相信大家对此都没有疑问。
对于日常使用到的技术,我们需要熟练掌握技术原理,积累使用经验,尤其是线上环境的问题处理经验。
第一个是基础。比如对集合类,并发包,IO/NIO,JVM,内存模型,泛型,异常,反射,等有深入了解,最好是看过源码了解底层的设计。
第二你需要有全面的互联网技术相关知识。从底层说起,你起码得深入了解mysql,redis,nginx,tomcat,rpc,jms等方面的知识。
第三就是编程能力,编程思想,算法能力,架构能力。
在这个过程中,打造自己的技能树,构建自己的技术体系。
对于不断冒出的新技术,我们一方面要了解清楚技术原理,也要了解新技术是为了解决什么问题,诞生于什么背景。
业务能力
前面说到技术是一种工具,解决的是现实世界的问题,如果我们希望更好的发挥技术的作用,那么就需要我们先掌握好业务领域。
互联网领域
如果你想要快速地入门互联网领域的业务,你可以使用AARRR漏斗模型来分析。
AARRR这5个字母分别代表 Acquisition、Activation、Retention、Revenue 和 Refer
五个英文单词,它们分别对应用户生命周期中的 5 个重要环节:获取(Acquisition)、激活(Activation)、留存(Retention)、收益(Revenue)和推荐(Refer)。
AARRR 模型的核心就是以用户为中心,以完整的用户生命周期为指导思想,分析用户在各个环节的行为和数据,以此来发现用户需求以及产品需要改进的地方。
举一个简单的例子,我们以一个互联网手游 LOL来举例:
获取就是用户通过广告、push等形式,了解到了游戏并注册或者登陆。
激活就是用户真正的开始游戏,比如开始了一场匹配。
留存就是用户在7天、30天内,登陆了几次,打了几把比赛,几天登陆一次,每日游戏时常又是多少。
收益,用户购买皮肤了,产生了收益。
推荐,用户邀请朋友,发送到微信群中,邀请了朋友一起开黑。
如果你所在的行业是C端产品,那么这个模型基本可以概括用户的生命周期全流程。
传统行业
传统行业没有比较通用的业务模型,如果想要入手,需要我们从以下三个角度去入手
- 这个行业的商业模式是什么,也就是靠什么赚钱的?比如售卖系统收费,收取服务费等
- 行业的规模如何?头部玩家有哪些?它们的模式有哪些特色?
- 这个行业的客户是谁、用户是谁?有哪些经典的作业场景?业务操作流程是什么样的?
如何获取到这些信息呢?有几种常见的形式
- 权威的行业研究报告,这个比较常见
- 直接关注头部玩家的官网、公众号、官媒
- 深入用户现场
我们以汽车行业来举例
商业模式:整车销售、二手车、汽车租赁等,细分一点,又有传统动力和新能源两种分类。
规模:如下图
头部车企:传统的四大车企一汽、东风、上汽、长安,新势力 特斯拉、蔚小理
经典场景:直接去4S店体验一下汽车销售模式、流程
说在最后
好了,文章到这里就要结束啦,我用我自己工作几年的不同阶段,给你介绍了我对于技术的本质是工具的思考过程,也浅浅的探寻了一下,未来的发展机会在哪里,以及我们应该如何提升自己,很感谢你能看到最后,希望对你有所帮助。
不知道你对于技术是怎么看的,又如何看待当下的市场环境呢?欢迎你在评论区和我分享,也希望你点赞、评论、收藏,让我知道对你有所收获,这对我来说很重要。也欢迎你加我的wx:Ldhrlhy10,一起交流。
本篇文章是第33篇原创文章,2024目标进度33/100,欢迎有趣的你关注我~
来源:juejin.cn/post/7365679089812553769
面试官:count(1) 和 count(*)哪个性能更好?
在数据库查询中,count(*)
和 count(1)
是两个常见的计数表达式,都可以用来计算表中行数。
很多人都以为 count(*)
效率更差,主要是因为在早期的数据库系统中,count(*)
可能会被实现为对所有列进行扫描,而 count(1)
则可能只扫描单个列。
但事实真是如此吗?
执行原理
先来看看这两者的执行原理:
count(*)
查询所有满足条件的行,包括包含空值的行。在大多数数据库中,count(*)
会直接统计行数,并不会实际去读取每行中的详细数据,因为数据库引擎会自行优化该计数操作,以提高执行效率。
count(1)
也是计算表中的行数,这里的 1 是一个常量,只是作为一个占位符,并没有实际的含义。与 count(*)
类似,数据库引擎也会对 count(1)
进行优化,以快速确定表中的行数。
count(*) 和 count(1) 的 性能差异
再说性能,在大多数数据库中,其实 count(*)
和 count(1)
的性能非常相似,甚至可以说没有区别,这是因为大多数数据库引擎对这两种计数方式进行相同的优化,并没有明显的执行效率上的差异。但是在特殊情况下可能会有细微的差异,造成这种差异的原因通常有以下几种:
1. 数据库引擎的差异
不同的数据库引擎可能对 count(*)
和 count(1)
采取不同的优化策略,这在某些情况下可能会导致两种计数方式的性能差异。例如:
- SQL Server:在某些版本的 SQL Server 中,
count(1)
在特定的查询计划中可能稍微快一些,但这种差异通常微乎其微,只有在处理非常大的表或复杂查询时才会显现出来。 - MyISAM 引擎:在不附加任何
WHERE
查询条件的情况下,统计表的总行数会非常快,因为 MyISAM 会用一个变量存储表的行数。如果没有WHERE
条件,查询语句将直接返回该变量值,使得速度很快。然而,只有当表的第一列定义为NOT NULL
时,count(1)
才能得到类似的优化。如果有WHERE
条件,则该优化将不再适用。 - InnoDB 引擎:尽管 InnoDB 表也存储了一个记录行数的变量,但遗憾的是,这个值只是一个估计值,并无实际意义。在 Innodb 引擎下,
count(*)
和count(1)
哪个快呢?结论是:这俩在高版本的 MySQL 是没有什么区别的,也就没有count(1)
会比count(*)
更快这一说了。
另外,还有一个问题是 Innodb 是通过主键索引来统计行数的吗?
如果该表只有一个主键索引,没有任何二级索引的情况下,那么 count(*)
和 count(1)
都是通过通过主键索引来统计行数的。
如果该表有二级索引,则 count(*)
和 count(1)
都会通过占用空间最小的字段的二级索引进行统计。
2. 索引的影响
如果表上有合适的索引,无论是count(1)
还是 count(*)
都可以利用索引来快速确定行数,而不必扫描整个表。在这种情况下,两者的性能差异通常可以忽略不计。例如,如果有一个基于主键的索引,数据库可以快速通过索引确定表中的行数,而无需读取表中的每一行数据。
实战分析
话不多说,下面我们通过实验来验证上述理论:
第一步:创建表与插入数据
用 Chat2DB 给我们生成一个创建表的 sql 语句,直接用自然语言描述我们想要的字段名和字段类型即可生成建表语句,也可以生成测试数据。
然后用存储过程向 student 表中插入两万条测试数据。(存储过程执行两次)
插入数据后的 student 表如下:
这个时候执行 select count(*) from student
和 select count(1) from student
可以看到解释器的结果如下,耗时均为 2 ms(两者一致,所以就只截了一张图),两者都用主键索引进行行数的统计:
第二步:执行计数查询
创建二级索引 IDCard 进行统计结果如下:
可以看出用二级索引进行统计的解释器结果还是一致。
结论
综上所述,count(1)
和 count(*)
的性能基本相同,并不存在 COUNT(1)
比 COUNT(*)
更快的说法。总体而言,在大多数情况下,两者之间的性能差异是可以忽略不计的。
在选择使用哪种方式时,应当优先考虑代码的可读性和可维护性。count(*)
在语义上更为明确,表示计算所有行的数量,而不依赖于任何特定的值。因此,从代码清晰度的角度出发,通常建议优先使用 count(*)
。
当然,如果在特定的数据库环境中,经过实际测试发现 count(1)
具有明显的性能优势,那么也可以选择使用 count(1)
。但在一般情况下,不必过分纠结于这两种计数方式之间的性能差异。
希望本文能帮助你在使用计数操作时作出更为合理的选择。
Chat2DB 文档:docs.chat2db.ai/zh-CN/docs/…
Chat2DB 官网:chat2db.ai/zh-CN
Chat2DB GitHub:github.com/codePhiliaX…
来源:juejin.cn/post/7417521775587065907
【后端性能优化】接口耗时下降60%,CPU负载降低30%
大家好,我是五阳。
GC 话题始终霸占面试必问排行榜,很多人对 GC 原理了然于胸,但是苦于没有实践经验,因此本篇文章将分享我的GC 优化实践。一个很小的优化,产生了非常好的效果。
现在五阳将优化过程给大家汇报一下。
一、背景
我所负责的 A 服务每天的凌晨会定时执行一个批量任务,每天执行时都会触发 GC 频率告警,偶尔单机 CPU 负载超过 60%时,会触发 CPU 高负载告警。
曾经有考虑过通过单机限流器,限制任务执行速率,从而降低机器负载。然而因为业务上希望定时任务尽快执行完,所以优化方向就放在了如何降低 CPU 负载,如何降低 GC 频率。
1.1 配置和负载
- 版本:java8
- GC 回收器:ParNew + CMS
- 硬件:8 核 16G 内存,Centos6.8
- 高峰期CPU 平均负载(分钟)超过 50%(每个公司计算口径可能不同。我司的历史经验超过 70%后,接口性能将会快速恶化)
1.2 优化前的 GC情况
不容乐观。
- 高峰期 Young GC频率 70次/min,单次 ygc 平均时间 125ms;
- 高峰期 Full GC频率 每 3 分钟 1 次;单次 fgc 平均时间 610ms。
1.3 GC 参数和 JVM 配置
参数配置 | 说明 |
---|---|
-Xmx6g -Xms6g | 堆内存大小为6G |
-XX:NewRatio=4 | 老年代的大小是新生代的 4 倍,即老年代占4.8G,新生代占1.2G |
-XX:SurvivorRatio=8 | Eden:From:To= 8:1:1,即Eden区占0.96G,两个Survivor区分别占0.12G |
-XX:ParallelCMSThreads=4 | 设置 CMS 垃圾回收器使用的并行线程数为 4 |
XX:CMSInitiatingOccupancyFraction=72 | 设置老年代使用率达到 72% 时触发 CMS 垃圾回收。 |
-XX:+UseParNewGC | 启用 ParNew 作为年轻代垃圾回收器 |
-XX:+UseConcMarkSweepGC | 启用 CMS 垃圾回收器 |
二、问题分析
2.1 增加 GC打印参数
由于打印GC信息不足,无法分析问题。因此添加了 以下GC 打印参数,以提供更多的信息
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintCommandLineFlags
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintReferenceGC
2.2 提前晋升现象
配置如上参数后,每次发生 younggc后,都会打印详细的 younggc 日志。通过分析 gc 日志,我发现日志中经常出现类似内容。
Desired survivor size 61054720 bytes, new threshold 2 (max 15)
new threshold是新的晋升阈值,是指对象在新生代经过 new threshold
轮 younggc后,就能晋升到老年代,这个值通过 MaxTenuringThreshold配置,默认值是 15,在原有理解中阈值是固定值 15,实际上这个值会动态调整。
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
Desired survivor 一般是 Survivor 区的一半。假设年龄 1至N 的对象大小,超过了 Desired size,那么下一次 GC 的晋升阈值就会调整为 N。举个例子,假设 age=1的对象为 80M,超过了 61M,那么下一次GC 的晋升阈值就是 1,所有超过 1 的对象都会晋升到老年代,无需等到年龄到 15。
如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
2.3 老年代增长速度过快
为了印证是否发生提前晋升,我通过监控查看到在事发时间,老年代内存的涨幅和 Survivor的内存基本一致,看来新生代的对象确实提前晋升到老年代了。
grep 分析历次 GC 后的晋升阈值后,我发现绝大部分情况下,新生的对象无法在 15 次 GC后进入到老年代,基本上三次以后就会提前晋升到老年代…… 这解释了为什么会发生频繁的 FullGC。
假设每次提前晋升 100M 到老年代,每分钟超过 15 次 ygc,则每分钟将会有 1.5G 对象进入老年代。
因为频繁地提前晋升,老年代的增长速度极快。 在高峰期时,往往 2 至 3 分钟左右,老年代内存就会触达 72% 的阈值,从而发生 FullGC。
2.4 新生代内存不足
即便老年代配置 4.8G 的大内存,但频繁地发生提前晋升,老年代也很快被打满。这背后的根本原因在于 新生代的内存太小了。 新生代,总共 1.2G 大小,Survivor才 120M,这远远不够。
于是我们调整了内存分配。调整后如下
- -Xmx10g -Xms10g -Xmn6g
- -XX:SurvivorRatio=8
- 堆内存由 6G 增加到 10G
- 大部分堆内存(6G)分配给新生代。新生代内存从 1.2G 增加到 6G。
- Eden:From:To 的比例依然是 8:1:1
- Eden大小从 0.96 G 增加到 4.8 G。
- Survivor区由 120 M 增加到 600 M。
三、优化效果
虽然改动不大,但是优化效果十分显著。由于公司监控有水印,我无法截图取证,敬请谅解。
3.1 GC频率明显下降
- 高峰期 ygc 70 次/min 降到了 12 次/min,下降幅度达83%(单机 500 QPS)
- 高峰期 fgc 三分钟1 次,降到了 每天 1 次 Full GC。
- younggc 和 fullgc 单次平均耗时保持不变。
3.2 CPU 负载降低 30%+
- 优化之前高峰期 cpu 平均负载超过 50%;优化后降到了不足 30%,高峰期负载下降了 40%。
- CPU负载每日平均值 由 29%,降到了 20%。日平均负载下降了 32%。
3.3 核心接口性能显著提升
核心接口耗时下降明显
- 接口 A 高峰期 TPS 100/秒,tp999 由 200毫秒 降到了 150 毫秒, tp9999 由 400 毫秒降到了 300 毫秒,接口耗时下降超过 25%!
- 接口 B 高峰期QPS 250/秒, tp999 由 190 毫秒降到了 120 毫秒, tp9999 由 450 毫秒下降到了 150 毫秒,接口耗时下降分别下降 37%和 67%!
- 接口 B 低峰期降幅更加明显,tp999 由 80 毫秒降到了 10 毫秒,下降幅度接近 90%!
后来又适当微调了 JVM 内存分配比例,但是优化效果不明显。
四、总结
经过此次 GC 优化经历,我学到了如下经验
- 要通过 GC 日志分析 GC 问题。
- 调整JVM 内存,保证足够的新生代内存。
- 优化 GC 可以降低接口耗时,提高接口可用性。
- 优化 GC 可以有效降低机器 CPU 负载,提高硬件使用率。
反过来当接口性能差、cpu负载高的时候,不妨分析一下 GC ,看看有没有优化空间。
详细了解如何分析 younggc 日志,可以参考我的另一篇文章。2024面试必问:系统频繁Full GC,你有哪些优化思路?第一步分析gc日志
关注五阳~ 了解更多我在大厂的实际经历
来源:juejin.cn/post/7423066953038741542
车机系统与Android的关系
前言:搞懂 Android 系统和汽车到底有什么关系。
一、基本概念
1、Android Auto
1)是什么
- Android Atuo 是一个 Android 端的 app,专门为驾驶环境设计的;
- 运行环境:需要在 Android 5.0 或者更高版本的系统,并且需要 Google 地图和 Google Play 音乐应用;
2)功能
- Android Atuo 可以用来将 Android 设备上的部分功能映射到汽车屏幕上;
- 满足了很多人在开车时会使用手机的需求;
2、Google Assistant
- Google 将 GoofleAssistant 集成到 AndroidAuto 中;
- 交互方式有键盘、触摸、语音等;
- 对于汽车来说,语音无疑是比触摸更好的交互方式;
- 在驾驶环境中,语音交换存在的优势
- 用户不改变自身的物理姿势,这种交互方式不影响驾驶员对驾驶的操作;
- 有需要多次触摸的交互时,可能只需要一条语音就可以完成;
- 语音交互不存在入口的层次嵌套,数据更加扁平;
- 优秀的语音系统可以利用对话的上下文完成任务,避免用户重复输入;
3、Android Automotive
1、Android Auto 和 Android Automotive 的区别
- Android Auto 是以手机为中心的
- 好处:数据和应用始终是一致的,不存在需要数据同步的问题,手机上装的软件和已有数据,接到汽车上就直接有了;
- 坏处:每次都需要拿出手机,汽车只是作为手机的一个外设;这种模式不便于对于汽车本身的控制和相关数据的获取;
- Android Automotive
- 如果将系统直接内置于汽车中,会大大提升用户体验;
- Android Automotive 就是面向这个方向进行设计的;
- 一旦将系统内置于汽车,可以完成的功能就会大大增加;例如,直接在中控触摸屏上调整座椅和空调;同时,系统也能获取更多关于汽车的信息,例如:油耗水平、刹车使用等;
加两张中控和仪表的图片
4、App
1)App 的开发
- Android Auto 目前仅支持两类第三方应用
- 音频应用:允许用户浏览和播放汽车中的音乐和语音内容;
- 消息应用:通过 text-to-speech 朗读消息并通过语音输入回复消息;
2)App 的设计
- Google 专门为 Android Auto 上的 UI 设计做了一个指导网站:Auto UI guidelines;
- 基本指导原则(车机交互系统的借鉴)
- Android Auto 上的互动步调必须由驾驶员控制;
- 汽车界面上的触摸目标必须足够大,以便可以轻松地浏览和点击;
- 适当的私彩对比可以帮助驾驶员快速解读信息并做出决定;
- 应用必须支持夜间模式,因为过高的强度可能会干扰注意力;
- Roboto 字体在整个系统中用于保持一致性并帮助提高可读性;
- 通过触摸来进行分页应用用来作为滑动翻页的补充;
- 有节制地使用动画来描述两个状态间的变化;
二、源码和架构
1、Android Automative的整体架构
- Android Automative 的源码包含在 AOSP 中;
- Android Automative 是在原先 Android的 系统架构上增加了一些与车相关的(图中虚线框中绿色背景的)模块;
- Car App:包括 OEM 和第三方开发的 App;
- OEM:就是汽车厂商利用自身掌握的核心技术负责设计和开发新产品,而具体的生产制造任务则通过合同订购的方式委托给同类产品的其他厂家进行,最终产品会贴上汽车厂商自己的品牌商标。这种生产方式被称为定牌生产合作,俗称“贴牌”。承接这种加工任务的制造商就被称为OEM厂商,其生产的产品就是OEM产品;
- Car API:提供给汽车 App 特有的接口;
- Car Service:系统中与车相关的服务;
- Vehicle Network Service:汽车的网络服务;
- Vehicle HAL:汽车的硬件抽象层描述;
- Car App:包括 OEM 和第三方开发的 App;
1)Car App
- /car_product/build/car.mk 这个文件中列出了汽车系统中专有的模块;
- 列表中,首字母大写的模块基本上都是汽车系统中专有的 App;
- App的源码都位于 /platform/packages/services/Car/ 目录下
# Automotive specific packages
PRODUCT_PACKAGES += \
vehicle_monitor_service \
CarService \
CarTrustAgentService \
CarDialerApp \
CarRadioApp \
OverviewApp \
CarLensPickerApp \
LocalMediaPlayer \
CarMediaApp \
CarMessengerApp \
CarHvacApp \
CarMapsPlaceholder \
CarLatinIME \
CarUsbHandler \
android.car \
libvehiclemonitor-native \
2)Car API
- 开发汽车专有的App自然需要专有的API;
- 这些API对于其他平台(例如手机和平板)通常是没有意义的;
- 所以这些API没有包含在Android Framework SDK中;
- 下图列出了所有的 Car API;
- android.car:包含了与车相关的基本API。例如:车辆后视镜,门,座位,窗口等。
- cabin:座舱相关API。
- hvac:通风空调相关API。(hvac是Heating, ventilation and air conditioning的缩写)
- property:属性相关API。
- radio:收音机相关API。
- pm:应用包相关API。
- render:渲染相关API。
- menu:车辆应用菜单相关API。
- annotation:包含了两个注解。
- app
- cluster:仪表盘相关API。
- content
- diagnostic:包含与汽车诊断相关的API。
- hardware:车辆硬件相关API。
- input:输入相关API。
- media:多媒体相关API。
- navigation:导航相关API。
- settings:设置相关API。
- vms:汽车监测相关API。
3)Car Service
- Car Service并非一个服务,而是一系列的服务。这些服务都在ICarImpl.java构造函数中列了出来;
public ICarImpl(Context serviceContext, IVehicle vehicle, SystemInterface systemInterface,
CanBusErrorNotifier errorNotifier) {
mContext = serviceContext;
mHal = new VehicleHal(vehicle);
mSystemActivityMonitoringService = new SystemActivityMonitoringService(serviceContext);
mCarPowerManagementService = new CarPowerManagementService(
mHal.getPowerHal(), systemInterface);
mCarSensorService = new CarSensorService(serviceContext, mHal.getSensorHal());
mCarPackageManagerService = new CarPackageManagerService(serviceContext, mCarSensorService,
mSystemActivityMonitoringService);
mCarInputService = new CarInputService(serviceContext, mHal.getInputHal());
mCarProjectionService = new CarProjectionService(serviceContext, mCarInputService);
mGarageModeService = new GarageModeService(mContext, mCarPowerManagementService);
mCarInfoService = new CarInfoService(serviceContext, mHal.getInfoHal());
mAppFocusService = new AppFocusService(serviceContext, mSystemActivityMonitoringService);
mCarAudioService = new CarAudioService(serviceContext, mHal.getAudioHal(),
mCarInputService, errorNotifier);
mCarCabinService = new CarCabinService(serviceContext, mHal.getCabinHal());
mCarHvacService = new CarHvacService(serviceContext, mHal.getHvacHal());
mCarRadioService = new CarRadioService(serviceContext, mHal.getRadioHal());
mCarNightService = new CarNightService(serviceContext, mCarSensorService);
mInstrumentClusterService = new InstrumentClusterService(serviceContext,
mAppFocusService, mCarInputService);
mSystemStateControllerService = new SystemStateControllerService(serviceContext,
mCarPowerManagementService, mCarAudioService, this);
mCarVendorExtensionService = new CarVendorExtensionService(serviceContext,
mHal.getVendorExtensionHal());
mPerUserCarServiceHelper = new PerUserCarServiceHelper(serviceContext);
mCarBluetoothService = new CarBluetoothService(serviceContext, mCarCabinService,
mCarSensorService, mPerUserCarServiceHelper);
if (FeatureConfiguration.ENABLE_VEHICLE_MAP_SERVICE) {
mVmsSubscriberService = new VmsSubscriberService(serviceContext, mHal.getVmsHal());
mVmsPublisherService = new VmsPublisherService(serviceContext, mHal.getVmsHal());
}
mCarDiagnosticService = new CarDiagnosticService(serviceContext, mHal.getDiagnosticHal());
4)Car Tool
a、VMS
- VMS全称是Vehicle Monitor Service。正如其名称所示,这个服务用来监测其他进程;
- 在运行时,这个服务是一个独立的进程,在init.car.rc中有关于它的配置
service vms /system/bin/vehicle_monitor_service
class core
user root
group root
critical
on boot
start vms
- 这是一个Binder服务,并提供了C++和Java的Binder接口用来供其他模块使用;
来源:juejin.cn/post/7356981730765291558
Flutter 用什么架构方式才合理?
前言
刚入门 Flutter 编程时,差点被 Flutter 的嵌套地狱吓走,不过当我看到 Flutter 支持 Windows 稳定后,于是下定决心尝试接受 Flutter,因为 Flutter 真的给的太多了:跨平台、静态编译、热加载界面。
Flutter 代码是写到文件夹中的,通过文件夹来管理代码,像是 c++ 语言那样,一个文件,即可以写类,也可以直接写方法😠。
不像 java 那样,全部都是类,整齐划一,通过包名来管理,但也支持类似的“导包”😆。
那么怎样才能像 Java 那样,有个框架优化代码,让项目看起来更整洁好维护呢?
我目前的答案是 MVC 🐷,合适自己的架构才是最好的架构,用这个架构,我感觉找到了家,大家先看看我的代码,然后再做评价。
使用部分
结合GetX, 使用方式如下:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:wenznote/commons/mvc/controller.dart';
import 'package:wenznote/commons/mvc/view.dart';
class CustomController extends MvcController {
var count = 0.obs;
void addCount() {
count.value++;
}
}
class CustomView extends MvcView<CustomController> {
const CustomView({super.key, required super.controller});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
Obx(() => Text("点击次数:${controller.count.value}")),
TextButton(
onPressed: () {
controller.addCount();
},
child: Text("点我"),
),
],
),
);
}
}
简单粗暴,直接在 CustomView 中设计 UI, 在 CustomController 中编写业务逻辑代码,比如登录注册之类的操作。
至于 MVC 中的 Model 去哪里了?你猜猜😘。
代码封装部分
代码封装也很简洁,封装的 controller 代码如下
import 'package:flutter/material.dart';
class MvcController with ChangeNotifier {
late BuildContext context;
@mustCallSuper
void onInitState(BuildContext context) {
this.context = context;
}
@mustCallSuper
void onDidUpdateWidget(BuildContext context, MvcController oldController) {
this.context = context;
}
void onDispose() {}
}
封装的 view 代码如下
import 'package:flutter/material.dart';
import 'controller.dart';
typedef MvcBuilder<T> = Widget Function(T controller);
class MvcView<T extends MvcController> extends StatefulWidget {
final T controller;
final MvcBuilder<T>? builder;
const MvcView({
super.key,
required this.controller,
this.builder,
});
Widget build(BuildContext context) {
return builder?.call(controller) ?? Container();
}
@override
State<MvcView> createState() => _MvcViewState();
}
class _MvcViewState extends State<MvcView> with AutomaticKeepAliveClientMixin{
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
widget.controller.onInitState(context);
widget.controller.addListener(onChanged);
}
void onChanged() {
if (context.mounted) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
super.build(context);
widget.controller.context = context;
return widget.build(context);
}
@override
void didUpdateWidget(covariant MvcView<MvcController> oldWidget) {
super.didUpdateWidget(oldWidget);
widget.controller.onDidUpdateWidget(context, oldWidget.controller);
}
@override
void dispose() {
widget.controller.removeListener(onChanged);
widget.controller.onDispose();
super.dispose();
}
}
结语
MVC 可以很简单快速的将业务代码和 UI 代码隔离开,改逻辑的时候就去找 Controller 就行,改 UI 的话就去找 View 就行,和后端开发一样的思路,完成作品就行。
附上的作品文件结构截图,亲喷哈~
感谢大家的关注与支持,后续继续更新更多 flutter 跨平台开发知识,例如:MVC 架构中的 Controller 应该在哪里创建?Controller 中的 Service 应该在哪里创建?
来源:juejin.cn/post/7340472228927914024
可视化大屏开发,知道这三个适配方案,效率翻倍!
哈喽,大家好 我是
xy
👨🏻💻。今天和大家来聊一聊大屏可视化
适配过程中的痛点以及怎么去解决
这些痛点!!!
前言
开发过大屏可视化
应用的前端工程师们通常会有这样的共识:
在可视化开发过程中,最具有挑战性的并非各种图表
的配置与效果展示
,而是如何确保大屏
在不同尺寸
的屏幕上都能实现良好的适配。
原始解决方案
起初比较流行的三大
解决方式:
rem 方案
- 动态设置
HTML
根字体大小和body
字体大小,配合百分比
或者vw/vh
实现容器宽高
、字体大小
、位移
的动态调整
vw/vh 方案
- 将
像素值
(px)按比例
换算为视口宽度
(vw)和视口高度
(vh),能够实时计算图表尺寸、字体大小等
scale 方案
- 根据
宽高比例
进行动态缩放,代码简洁,几行代码即可解决,但是遇到一些地图
或者Canvas
中的点击事件,可能会存在错位问题,需要做针对性的调整适配
以上三种方式,都能够实现大屏的基本适配!
但是在开发过程中需要对每个字体
和容器
去做相应的计算调整,相对来说较为繁琐
,而且在团队协作
过程中也容易出现问题。
那么有没有一种方式,只需要简单的一些配置
,就能完全搞定大屏在不同尺寸的屏幕上都能实现良好的适配
?
以下给大家推荐三个方案
,只需要简单的几行代码配置,可以完全解决大屏开发中的适配问题,让你效率翻倍!!!
autofit.js
autofit.js
基于比例缩放
原理,通过动态调整容器的宽度和高度来实现全屏填充,避免元素的挤压或拉伸。
autofit.js 提供了一种简单而有效的方法来实现网页的自适应设计,尤其适合需要在不同分辨率
和屏幕尺寸
下保持布局一致性的应用场景。
安装:
npm i autofit.js
配置:
import autofit from 'autofit.js';
onMounted(() => {
autofit.init({
el: '#page',
dw: 375,
dh: 667
})
})
* - 传入对象,对象中的属性如下:
* - el(可选):渲染的元素,默认是 "body"
* - dw(可选):设计稿的宽度,默认是 1920
* - dh(可选):设计稿的高度,默认是 1080
* - resize(可选):是否监听resize事件,默认是 true
* - ignore(可选):忽略缩放的元素(该元素将反向缩放),参数见readme.md
* - transition(可选):过渡时间,默认是 0
* - delay(可选):延迟,默认是 0
源码地址
Github 地址:
https://github.com/995231030/autofit.js
v-scale-screen
大屏自适应容器组件
,可用于大屏项目开发,实现屏幕自适应,可根据宽度自适应
,高度自适应
,和宽高等比例自适应
,全屏自适应
(会存在拉伸问题),如果是 React
开发者,可以使用 r-scale-screen
。
安装:
npm install v-scale-screen
# or
yarn add v-scale-screen
配置:
<template>
<v-scale-screen width="1920" height="1080">
<div>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
<v-chart>....</v-chart>
</div>
</v-scale-screen>
</template>
<script>
import { defineComponent } from 'vue'
import VScaleScreen from 'v-scale-screen'
export default defineComponent({
name: 'Test',
components: {
VScaleScreen
}
})
</script>
源码地址
github 地址:
https://github.com/Alfred-Skyblue/v-scale-screen
FitScreen
一种基于缩放
的大屏自适应解决方案的基本方法,一切都是基于设计草图的像素尺寸,通过缩放
进行适配
,一切都变得如此简单。
支持 vue2
、vue3
以及 react
,可以适用于任何框架,只要一点点代码。
安装:
npm install @fit-screen/vue
# or
yarn add @fit-screen/vue
# or
pnpm install @fit-screen/vue
配置:
<script setup>
import FitScreen from '@fit-screen/vue'
</script>
<template>
<FitScreen :width="1920" :height="1080" mode="fit">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<HelloWorld msg="Vite + Vue" />
</FitScreen>
</template>
源码地址
github 地址:
https://github.com/jp-liu/fit-screen
最后,如果大家有更好的适配方案
,欢迎在评论区留言一起学习交流!👏
最后
如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:
前端开发爱好者
回复加群,一起学习前端技能 公众号内包含很多实战
精选资源教程,欢迎关注
来源:juejin.cn/post/7386514632725872674
转转的Flutter实践之路
前言
跨端技术一直是移动端开发领域的热门话题,Flutter 作为一种领先的移动跨端技术之一,凭借其快速的渲染引擎、丰富的UI组件库和强大的开发工具,成为了开发人员的首选之一。
从 Flutter 诞生之初,我们就一直关注着它的发展,Flutter 早期版本变更较为频繁,并且经常伴随着 Breaking Change,另外可用的三方插件较少且不稳定。直到2019年,Flutter 的热度暴涨,国内不少团队陆续把 Flutter 引入到了生产环境使用,社区也涌现出不少优秀的开源项目,我们也决定在这个时候做一些技术上的尝试。
经过这几年在 Flutter 技术上的不断学习、探索和积累,Flutter 已经成为了客户端技术体系中的重要组成部分。
回顾整个过程,我们大致经历了这么几个阶段:可行性验证、基建一期建设、小范围试验、基建二期建设、大范围推广、前端生态的探索,下文将分别对每个阶段展开进行介绍。
可行性验证
其实在这之前我们已经做过了一些调研,但许多结论都是来源于网上的一些文章或者其它团队的实践,这些结论是否靠谱是否真实还有待商榷,另外,网上的文章大都千篇一律,要么使劲吹捧,要么使劲贬低,要得出相对客观的结论还是得需要我们自己通过实践才能得出。
目标
我们确定了以下几个维度,用来评估 Flutter 是否值得我们进一步投入:
- 开发效率
- UI一致性
- 性能体验
- 学习成本
- 发展趋势
由于前期对 Flutter 的熟练度不高,基础设施也还没有搭建起来,所以在开发效率上,我们期望的 Flutter 的开发耗时能保持在原生开发耗时的 1.5 倍以内,不然虽然实现了跨端,但是需求的开发周期反而被拉长了,这样得不偿失。在UI一致性上,我们期望同一份代码在两端的表现要基本达到一致,不需要额外的适配成本。在性能方面,尽量保证崩溃、卡顿、内存、帧率这些指标在可控范围内。
方案
我们希望用较小的代价完成上述维度的评估,所以在试验期间的架构及基础设施方面我们做的比较简单。
测试目标
当时我们正在做一个叫切克的 App,用户量级比较小,工程架构也相对简单一些,正好可以用来做一些技术方面的探索和验证。
我们选择的是切克的商品详情页,用 Flutter 技术实现了一个一模一样的商详,按1:1的流量分配给 Native 和 Flutter。
项目架构
由于我们的工程不是一个全新的项目,所以采用的是 Native 与 Flutter 混合开发的方式,Native 主工程只依赖 Flutter 产物即可,同时也尽量避免对原有工程的影响。
关于混合页面栈的问题,我们没有额外处理,因为暂时只测试一个页面,不会涉及到多页面混合栈的问题,所以暂时先忽略。
构建流程
为了降低验证成本,我们没有对接现有的 Native 的持续集成流程,而是直接在本地构建 Flutter 产物,然后上传到远程仓库。
结论
经过一段时间的线上验证,我对 Flutter 技术基本有了一个比较全面的了解:
在开发效率上由于基础库和基建的缺失,在处理 Flutter 业务跟 Native 业务的交互时需要更多的适配成本,包括像页面跳转、埋点上报、接口请求、图片加载等也需要额外的处理,但我们评估随着后续基建的不断完善,这部分的效率是可以逐步得到改善的;而在涉及UI开发方面,得益于热重载等技术,Flutter 的开发效率是要优于原生开发的。整体评估下来,在开发效率方面 Flutter 是符合我们的预期的。
在UI一致性上,除了在状态栏控制和文本在某些情况下需要特殊适配下外,其它控件在两端的表现基本一致。
在性能表现上,Flutter 会额外引入一些崩溃,内存占用也有所上涨,但还在可接受范围内。
Flutter 的学习成本相对还是比较高,毕竟需要单独学习一门语言,另外 Flutter 的渲染原理也跟原生有很多差异,需要转变思维才能更快的适应,此外 Flutter 还提供了众多的 Widget 组件,也需要较长时间学习。
在发展趋势上,Flutter 无疑是当时增长最快的跨端技术之一,社区的活跃程度以及官方的投入都非常高,国内不少团队也都在积极推进 Flutter 技术的发展,Flutter 正处在一个快速的上升期。
整体来说,Flutter 是满足我们团队对跨平台技术的需求的,我们计划在接下来的一段时间投入更多资源,把 Flutter 的基础设施逐渐建立起来。
基建一期建设
基建一期内容主要包括以下几个方面:
- 工程架构
- 开发框架
- 脚本工具
- 自动化构建
在基建一期完成后,我们的目标是要达到:
- 基础能力足够支撑普通业务开发
- 开发效率接近原生开发
- 开发过程要基本顺畅
工程架构
工程架构指的是原生工程与 Flutter 工程之间的关系,以及 Flutter 工程与 Flutter 工程之间的关系。
原生工程与Flutter工程的关系
我们知道,使用 Flutter 开发通常有两种情况,一种是直接使用 Flutter 开发一个新的App,属于纯 Flutter 开发;一种是在已有的 Native 工程中引入,属于混合开发。我们当然属于后者。
而混合开发又可分为两种:源码集成和产物集成。源码集成需要改变原工程的项目结构,并且需要 Flutter 开发环境才能编译,而产物集成则不需要改动原工程的项目结构,只需把 Flutter 的构建产物当作普通的依赖库引入即可,原有 Native 工程和 Flutter 工程从物理上完全独立。显而易见的我们选择产物集成的方式,引入 Flutter对于原工程以及非 Flutter 开发人员来说,基本上是毫无感知的。
所以原生工程与 Flutter 工程之间的关系如下图所示:
Flutter工程之间的关系
根据已有的客户端基建的开发经验,我们将所有 Flutter 工程分为了四层:
- 壳工程
- 业务层
- 公共层
- 容器层
容器层负责提供 Flutter 的基础运行环境,包括 Flutter 引擎管理、页面栈管理、网络框架、KV存储、数据库访问、埋点框架、Native 与 Flutter 通信通道和其它基础功能。
公共层包含一些通用的开源库、自定义UI组件、部分通用业务等。
业务层包含用户信息、商品、发布等业务组件。
壳工程负责集成各业务组件,最终构建出产物集成到 Native 主工程。
其中业务层、公共层、容器层都是由若干个独立的工程所组成,整体结构如下:
开发框架
开发框架是为了提高开发效率、规范代码结构、减少维护成本等考虑而设计的一套软件框架,包括:基础能力、状态管理、页面栈管理等。
基础能力
开发框架需要提供各种必要的能力,比如:页面跳转、埋点、网络请求、图片加载、数据存储等,为了最大化减少研发成本,我们在底层定义了一套通用的数据交互协议,直接复用了现有的 Native 的各项能力,也使得 Native 的各种状态与 Flutter 侧能够保持统一。
状态管理
相信了解 Flutter 的同学一定知道状态管理,这也是跟 Native 开发区别较大的地方。在开发较为复杂的页面时,状态维护是非常繁琐的,在不引入状态管理框架的情况下,开发效率会受很大影响,后期的维护成本以及业务交接都是很大的问题。
另外,在开发框架设计之初,我们就期望从框架上能够在一定程度上限定代码结构、模块之间的交互方式、状态更新方式等,我们期望的是不同的人写出来的代码在逻辑、结构和风格上都能保持比较统一,即在提高开发效率的同时,也能保证项目后续的可维护性和扩展性,减少不同业务间的交接成本。
基于上述这些需求,在我们对比了多个开源项目后,FishRedux 的整体使用感受正好符合我们的要求。
如下图,两个页面的代码结构基本一致:
页面栈管理
在早期版本,Flutter 引擎的实例占用内存较高,为了减少内存消耗,大家普遍采用单实例的模式,而在 Native 和 Flutter 混合开发的场景下就会存在一个问题,就是 Native 有自己的页面栈,而 Flutter 也维护着一套自己的页面栈,如果 Native 页面与 Flutter 页面穿插着打开,在没有特殊处理的情况下,页面栈会发生错乱。在调研了业内的各种开源方案后,我们选择引入 FlutterBoost 用来管理页面混合栈。
脚本工具
为了方便开发同学搭建 Flutter 的开发环境,同时能够管理使用的 Flutter 版本,我们开发了 zflutter 命令行工具,包含以下主要功能:
- Flutter开发环境安装
- Flutter版本管理
- 创建模版工程(主工程、组件工程)
- 创建模版页面(常规页面、列表页、瀑布流页面)
- 创建页面模块
- 组件工程发布
- 构建Flutter产物
- 脚本自更新
如图:
自动化构建
客户端使用的是自研的 Beetle 平台(集工程管理、分支管理、编译、发布于一体),短时间内要支持上 Flutter 不太现实,基于此,我们先临时自己搭台服务器,通过 gitlab 的 webhook 功能结合 zflutter 工具简单实现了一套自动化构建的服务,待 Beetle 支持 Flutter 组件化开发功能后,再将工作流切回到 Beetle 平台。
小范围试验
在完成基建一期的开发工作后,我们决定通过开发几个实际业务来试验目前的基础设施是否达到既定目标。
我们以不影响主流程、能覆盖常见UI功能、并且能跟 Native 页面做AB测试(主要是方便在出问题时能够切换到 Native 版本)为条件挑选了个人资料页和留言列表页进行了 Flutter 化改造,如下图所示:
这两个页面涵盖了网络请求、图片加载、弹窗、列表、下拉刷新、上拉加载更多、左滑删除、埋点上报、页面跳转等常见功能,足以覆盖日常开发所需的基础能力。
经过完整的开发流程以及一段时间的线上观察,我们得出如下结论:
基础能力
目前已具备的基础能力已经足够支撑普通业务开发(开发过程中补足了一些缺失的能力)。
工作流
整个开发过程在工程依赖管理和分支管理方面的支持还比较缺失,比较依赖人工处理。
开发效率
我们在开发前根据页面功能同时做了纯 Native 开发排期和 Flutter 开发排期,按单人日的成本来对比的话,Flutter 实际开发耗时跟 Native 排期耗时比为 1.25:2,Native 是按照 Android+iOS 两端各一人算的,也就是1.25人/日比2人/日,如果后续对 Flutter 技术熟悉度提升后相信效率还可以进一步提升。
性能体验
线上两个 Flutter 页面的体验效果跟 Native 对比基本感觉不到差别,但是首次进入 Flutter 页面时会有短暂的白屏等待时间,这个是由于 Flutter 环境初始化导致的延迟,后续可以想办法优化。
包体积
在引入 Flutter 之后,转转的安装包体积在两端都分别有所增加:
- Android增加6.1M
- iOS增加14M
试验结果基本符合预期,包体积的增量也在我们的可接受范围内,接下来将进行基建二期的建设,补足目前缺失的能力。
基建二期建设
基建二期的内容主要包含以下工作:
- 配合工程效率组完成 Beetle 对 Flutter 项目的支持
- 组织客户端内部进行 Flutter 技术培训
Beetle支持Flutter
为了能让大家更清晰的了解 Beetle 的工程管理机制,这里先简单介绍下客户端的工程类型:
- Native主工程(又分为 Android 和 iOS)
- Native组件工程(又分为 Android 和 iOS)
- Flutter主工程
- Flutter组件工程(即 Flutter 插件工程)
举个例子,当有一个新版本需要开发时,先从 Native 主工程创建一个版本同时创建一个 Release 分支,即版本分支,然后从版本分支根据具体需求创建对应 Native 组件的版本分支,Flutter 主工程此时可看作是一个 Native 组件,比如此时创建了一个 Flutter 主工程的版本分支后,可以进入 Flutter 主工程再根据需要创建对应的 Flutter 组件工程的版本分支。
Beetle 目前已支持 Flutter 工程管理、分支管理、组件依赖管理以及组件的发布、Flutter 产物的构建等,Beetle 的作用贯穿从开发到上线的整个工作流。
Flutter技术培训
为了让大家更快的熟悉 Flutter 开发,我们在客户端内部组织了5次 Flutter 快速入门的系列分享:
同时也逐步完善内部文档的建设,包括:FlutterSdk 源码维护策略、Flutter 入门指南、Flutter 混合开发方案、Flutter 与 Native 通信方案、Flutter 开发环境配置、Flutter 组件化工程结构、Flutter 开发与调试、Flutter 开发工作流、ZFlutter 工具使用介绍、Flutter 开发之 Beetle 使用指南等,涵盖了从环境搭建、开发调试到构建发布的整个过程。
大范围推广
在完成基建二期的建设后,整体基础设施已经能够支撑我们常见的业务,开发工作流也基本顺畅,于是我们开始了在内部大范围推广计划。
我们先后改造和新开发了个人主页、我发布的页面、微商详、奇趣数码页等业务,基本涵盖了常见的各种类型的页面和功能,整体开发效率与原生单端开发效率持平,但是在特别复杂的页面的性能表现上,Flutter 的表现相对要差一些。
部分页面如下图所示:
探索前端生态
在跨端技术领域我们知道 Web 技术是天然支持的,如果能把前端生态引入到 Flutter 中,那么对客户端来说,在业务的支持度上会更上一个台阶,Web 的体验得到提升的同时客户端也具备了动态化,基于此背景我们开始探索 Flutter 在 Web 上的可能性。
技术调研
当时可选的开源方案有:Kraken、MXFlutter、Flutter For Web。
Kraken
Kraken 是一款基于 W3C 标准的高性能渲染引擎。Kraken 底层基于 Flutter 进行渲染,通过其自绘渲染的特性,保证多端一致性。上层基于 W3C 标准实现,拥有非常庞大的前端开发者生态。
Kraken 的最上层是一个基于 W3C 标准而构建的 DOM API,在下层是所依赖的 JS 引擎,通过 C++ 构建一个 Bridge 与 Dart 通信。然后这个 C++ Bridge 把 JS 所调用的一些信息,转发到 Dart 层。Dart 层通过接收这些信息,会去调用 Flutter 所提供的一些渲染能力来进行渲染。
Kraken 是不依赖 Flutter Widget,而是依赖 Flutter Widget 的底层渲染数据结构 —— RenderObject。Kraken 实现了很多 CSS 相关的能力和一些自定义的 RenderObject,直接将生成的 RenderObject 挂载在 Flutter RenderView 上来进行渲染,通过这样的方式能够做到非常高效的渲染性能。
MXFlutter
MXFlutter 是一套使用 TypeScript/JavaScript 来开发 Flutter 应用的框架。
MXFlutter 把 Flutter 的渲染逻辑中的三棵树(即:WidgetTree、Element、RenderObject )中的第一棵(即:WidgetTree),放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,实现了轻量的响应式 UI 框架,支撑JS WidgetTree 的 build逻辑,build 过程生成的UI描述, 通过Flutter 层的 UI 引擎转换成真正的 Flutter 控件显示出来。
Flutter For Web
Flutter 在 Web 平台上以浏览器的标准 API 重新实现了引擎。目前有两种在 Web 上呈现内容的选项:HTML 和 WebGL。
- 在 HTML 模式下,Flutter 使用 HTML、CSS、Canvas 和 SVG 进行渲染。
- 在 WebGL 模式下,Flutter 使用了一个编译为 WebAssembly 的 Skia 版本,名为 CanvasKit。
HTML 模式提供了最佳的代码大小,CanvasKit 则提供了浏览器图形堆栈渲染的最快途径,并为原生平台的内容提供了更高的图形保真度。
结论
我们对以上方案从接入成本、渲染性能、包体积、开发生态、学习成本等多维度进行了对比:
- 接入成本:Kraken ≈ MXFlutter ≈ Flutter For Web
- 渲染性能:Kraken > MXFlutter > Flutter For Web
- 包体积增量:Flutter For Web < Kraken < MXFlutter
- 开发生态:Kraken ≈ MXFlutter > Flutter For Web
- 学习成本:Flutter For Web < Kraken ≈ MXFlutter
最终选择了 Kraken 作为我们的首选方案。
上线验证
为了使 Kraken 顺利接入转转App,我们做了以下几个方面的工作:
- 升级 FlutterSdk 到最新版,满足接入 Kraken 的基础条件
- 统一客户端容器接口,使得 Kraken 容器能够完美继承 Web 容器的能力
- 自己维护 Kraken 源码,及时修复官方来不及修复的问题,方便增加转转特有的扩展能力
- 制定 Kraken 容器与 Web 容器的降级机制
- 兼容 HTML 加载,保持跟 Web 容器一致的加载方式
- 添加监控埋点,量化指标,指导后续优化方向
- 选择一个简单 Web 页并协助前端同学适配
上线后,我们对页面的各项指标进行了对比,使用 Kraken 容器加载比使用 WebView 加载,在首屏加载耗时的指标上平均增加了281毫秒,原因为:当前版本的 Kraken 容器不支持直接加载 HTML,且只能加载单个 JsBundle,导致加载效率比 WebView 差。
通过跟前端同学沟通,从开发效率上来看,Kraken 工程的开发周期会比实现同样需求的普通 Web 工程增加1.5到2倍的时间,主要原因是受到 CSS 样式、Api 差异,无法使用现有UI组件,另外 Kraken 的调试工具目前还不够完善,使用浏览器调试后还须在客户端容器中调试,整体下来导致开发 Kraken 工程会比开发普通Web工程耗费更多时间。
再次验证
由于之前选择的 Web 页面太过简单,不具备代表性,所以我们重新选定了“附近的人”页面做为改造目标,再次验证 Kraken 在实际开发过程中的效率及性能体验。页面如图所示:
最终因为部分问题得不到解决,并且整体性能较差,导致页面没能成功上线。
存在的问题包括但不限于下面列举的一些:
- 表现不一致问题
- CSS 定位、布局表现与浏览器表现不一致
- 部分 API 表现与浏览器不一致(getBoundingClientRect等)
- iOS,Android系统表现不一致
- 重大 Bug
- 页面初始化渲染完成,动态修改元素样式,DOM不重新渲染
- 滑动监听计算导致 APP 崩溃
- 调试成本高
- 不支持 vue-router,单项目单路由
- 不支持热更新,npm run build 预览
- 不支持 sourceMap,无法定位源代码
- 真机调试只支持 element 和 network;dom 和 element 无法互相选中;无法动态修改 dom 结构,无法直接修改样式.......
- 页面白屏,假死
- 安全性问题
- 无浏览器中的“同源策略”限制
- 兼容性
- npm 包不兼容等
通过这一系列的探索和尝试,我们了解到了 Kraken 目前还存在许多不足,如果继续应用会带来高额的开发调试以及维护成本,所以暂时停止了在 Kraken 方向上的投入,但我们仍然在这个方向上保持着关注。
结尾
目前转转在Flutter方向上的实践和探索只是一个起点,我们意识到仍然有很多工作需要去做。我们坚信Flutter作为一项领先的跨端技术,将为转转业务的发展带来巨大的潜力和机会。我们将持续努力,加强技术建设,不断完善实践经验,推动Flutter在转转的应用和发展,为用户提供更好的产品和体验。
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~
来源:juejin.cn/post/7304831120709697588
使用 uni-app 开发 APP 并上架 IOS 全过程
教你用 uni-app 开发 APP 上架 IOS 和 Android
介绍
本文记录了我使用uni-app开发构建并发布跨平台移动应用的全过程,旨在帮助新手开发者掌握如何使用uni-app进行APP开发并最终成功上架。通过详细讲解从注册开发者账号、项目创建、打包发布到应用商店配置的每一步骤,希望我的经验分享能为您提供实用的指导和帮助,让您在开发之旅中少走弯路,顺利实现自己的应用开发目标。
环境配置
IOS 环境配置
注册开发者账号
如果没有开发者账号需要注册苹果开发者账号,并且加入 “iOS Developer Program”,如果是公司项目那么可以将个人账号邀请到公司的项目中。
获取开发证书和配置文件
登录Apple Developer找到创建证书入口
申请证书的流程可以参考Dcloud官方的教程,申请ios证书教程
开发证书和发布证书都申请好应该是这个样子
创建App ID
创建一个App ID。App ID是iOS应用的唯一标识符,稍后你会在uni-app项目的配置文件中使用它。
配置测试机
第一步打开开发者后台点击Devices
第二步填写UDID
第三步重新生成开发证书并且勾选新增的测试机,建议一次性将所有需要测试的手机加入将来就不用一遍遍重复生成证书了
Android 环境配置
生成证书
Android平台签名证书(.keystore)生成指南: ask.dcloud.net.cn/article/357…
uni-app 项目构建配置
基础配置
版本号versionCode 前八位代表年月日,后两位代表打包次数
APP 图标设置
APP启动界面配置
App模块配置
注意这个页面用到什么就配置什么不然会影响APP审核
App隐私弹框配置
注意根据工业和信息化部关于开展APP侵害用户权益专项整治要求应用启动运行时需弹出隐私政策协议,说明应用采集用户数据,这里将详细介绍如何配置弹出“隐私协议和政策”提示框
详细内容可参考Uni官方文档
注意!androidPrivacy.json不要添加注释,会影响隐私政策提示框的显示!!!
在app启动界面配置勾选后会在项目中自动添加androidPrivacy.json文件,可以双击打开自定义配置以下内容:
{
"version" : "1",
"prompt" : "template",
"title" : "服务协议和隐私政策",
"message" : " 请你务必审慎阅读、充分理解“服务协议”和“隐私政策”各条款,包括但不限于:为了更好的向你提供服务,我们需要收集你的设备标识、操作日志等信息用于分析、优化应用性能。<br/> 你可阅读<a href="https://xxx.xxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxxx.xxxx.com/privacyPolicy.html">《隐私政策》</a>了解详细信息。如果你同意,请点击下面按钮开始接受我们的服务。",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "system|default",
"backToExit" : "false",
"second" : {
"title" : "确认提示",
"message" : " 进入应用前,你需先同意<a href="https://xxx.xxxx.com/userPolicy.html">《服务协议》</a>和<a href="https://xxx.xxxx.com/userPolicy.html">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"loadNativePlugins" : false,
"showAlways" : false
},
"styles" : {
"backgroundColor" : "#fff",
"borderRadius" : "5px",
"title" : {
"color" : "#fff"
},
"buttonAccept" : {
"color" : "#22B07D"
},
"buttonRefuse" : {
"color" : "#22B07D"
},
"buttonVisitor" : {
"color" : "#22B07D"
}
}
}
我的隐私协议页面是通过vite打包生成的多入口页面进行访问,因为只能填一个地址所以直接使用生产环境的例如:xxx.xxxx.com/userPolicy.…
构建打包
使用HBuilderX进行云打包
IOS打包
构建测试包
第一步 点击发行->原生app云打包
第二步配置打包变量
运行测试包
打开HbuildX->点击运行->运行到IOS App基座
选择设备->使用自定义基座运行
构建生产包
和构建测试包基本差不多,需要变更的就是ios证书的profile文件和密钥证书
构建成功后的包在dist目录下release文件夹中
上传生产包
上传IOS安装包的方式有很多我们选择通过transporter软件上传,下载transporter并上传安装包
确认无误后点击交付,点击交付后刷新后台,一般是5分钟左右就可以出现新的包了。
App store connect 配置
上传截屏
只要传6.5和5.5两种尺寸的就可,注意打包的时候千万不能勾选支持ipad选项,不然这里就会要求上传ipad截屏
填写app信息
配置发布方式
自动发布会在审核完成后直接发布,建议选手动发布
配置销售范围
配置隐私政策
配置完之后IOS就可以提交审核了,不管审核成功还是失败Apple都会发一封邮件通知你审核结果
安卓打包
构建测试包
构建的包在dist/debug目录下
运行测试包
如果需要运行的话,点击运行 -> 运行到Android App底座
构建生产包
构建后的包在dist目录下release文件夹中
构建好安卓包之后就可以在国内的各大手机厂商的应用商店上架了,由于安卓市场平台五花八门就不给大家一一列举了。
参考链接:
结语
本文介绍了使用uni-app开发并发布跨平台移动应用的完整流程,包括注册开发者账号、项目创建、打包发布以及应用商店配置,帮助开发者高效地将应用上架到iOS和Android平台。感谢您的阅读,希望本文能对您有所帮助。
来源:juejin.cn/post/7379958888909029395
我是如何实现网页颜色自适应的
前言
不知大家有没有留意过,当前大部分 App 或网页中,很少存在允许用户完全自定义要展示信息的颜色的功能。
例如在钉钉的自定义表情中,只允许用户从一组预设的配色中随机切换:
再比如笔记应用 Notion 虽然允许用户改变文本颜色,但也只允许在一组预设色值中选取:
原因无它,配色,不是一件容易事。
对于大众用户而言,没什么颜色理论知识,很可能挑出来的颜色在应用中很难看、看不清,这会极大的影响用户的使用体验(即使是用户自己造成的)。
因此大部分产品选择的做法是提供一组预先检验过的、不会对用户阅读造成困扰的颜色,放在应用中供用户挑选。
今天我来斗胆挑战一下这个业界难题。
在这篇文章中将会探讨两个具体问题:
- 如何让文本颜色自适应背景色
- 如何允许用户完全自定义主题色,同时保证可阅读性
文本颜色自适应背景色
在下面这张图中,文本的颜色默认都是黑色的,背景色设置了多个明暗不同的颜色。可以看到对于暗色的背景色,此时文本可阅读性特别差(不太明显,想看清楚会很累)。
如果能够自动根据背景色的明暗,决定使用白色还是黑色的文本,那便是实现了文本颜色的自适应了。
首先介绍下借助第三方库实现的方案。
第三方库实现
color
是 JavaScript 生态中在颜色处理方面最流行的库,它有诸多功能:颜色空间转换、颜色通道分解、获取对比度、颜色混合……
在文本颜色自适应这个场景中,最为方便的两个 API 是 isDark()
和 isLight()
,它们分别用来表示一个颜色是否为深色、是否为浅色。
实际应用:
import Color from 'color'
const BgColors = ['#f87171', '#fef08a', '#042f2e', ...]
export default function Page() {
return (
<main>
{BgColors.map((bg) => (
<div
style={{
background: bg,
// 根据背景是否为深色决定文本用白色还是黑色
color: Color(bg).isDark() ? 'white' : 'black'
}}
>
恍恍惚惚
</div>
))}
</main>
)
}
实际效果:
很 Nice ~
下面再来看下使用 CSS 的解决方案。
mix-blend-mode: difference
mix-blend-mode: difference
用于指定一个元素的颜色与背景色进行「差值」混合,可以使用如下公式表达:
# || 表示取绝对值
# 最终元素显示的颜色 = |元素原有的颜色 - 背景色|
result_color = | element_color - background_color |
例如:
- 文本颜色为白色
rgb(255 255 255)
背景色为蓝色rgb(0 0 255)
,最终文本颜色为黄色rgb(255 255 0)
- 文本颜色为黑色
rgb(0 0 0)
,此时无论背景色是什么颜色,最终文本的颜色一定和背景色完全相同,因为| 0 - x | = x
下面来看个实际的 demo,这里我们让文本颜色为 rbg(255, 255, 255)
,背景色动态调整:
可以看到这个方案会有如下问题:
- 当背景色为灰色时,文本的颜色和背景色很相似,对比度低,可阅读性差
- 当背景色为彩色时,混合出来的颜色也是彩色,而且颜色比较脏,不太美观
CSS 相对颜色
以 CSS 颜色函数 rgb()
举例,相对颜色的语法是通过 from
关键词扩展了该函数的能力:
color: rgb(from red r g b);
由于 red
的 RGB 是 (255, 0, 0)
,因此后面的 r g b
值分别为 255 0 0
。对于 r g b
还可以调用 calc()
或其他 CSS 函数进一步处理。
除了 rgb()
, rgba()
hsl()
hwb()
lch()
等等 CSS 颜色函数都是支持相对颜色语法的。
CSS 相对颜色语法带来的能力有调节亮度、调节饱和度、获取反转色、获取补充色……其中反转色就可以用于文本颜色自适应的场景中。
在上面 Demo 上,使用如下规则使文本的颜色为背景色的反转色:
color: rgb(from var(--bg) calc(255 - r) calc(255 - g) calc(255 - b));
实际效果:
可以看到虽然能一定程度上提升可阅读性,但是有些反转色奇奇怪怪的,和背景色搭配起来实在不美观,并不是特别推荐使用。
color-constract
color-constract()
可以说是最适合文本颜色自适应场景的 CSS 函数了,用法简单,效果好!
color: color-constract(var(--bg) vs color1, color2, ...);
它的作用即从 vs
右边的一堆色值中,挑选一个和 vs
左边的色值对比度最大的返回。
如 color-constract(#ccc vs #000, #fff)
,由于 #ccc
是个浅灰色,色值和 #000
对比度更大,因此这个函数会返回 #000
。
很美好。
但是!这个函数现在几乎不能用!
目前只有 Safari 中能使用,而且必须开启相关的实验性功能 Flag 。
完全允许用户自定义主题色
让文本的颜色根据背景色自适应,本质是在背景色(background)未知、前景色(foreground)有限的情况下,选择一个合适的前景色,使页面的可读性得到保障。
在上面的 Demo 中,背景色有淡紫色、淡黄色、深绿色、深棕色……前景色即文本的颜色只会有白色、黑色两种情况,依据背景色的明暗决定使用白色还是黑色。
相反的,我们讨论下前景色未知、背景色有限的情况。
看下这个实际应用场景:
在这款应用中,支持明、暗两种主题,明亮主题为左边的白色(#FFFFFF),暗夜主题为右边的深蓝色(#020617),这是两个背景色;标签主题色(即前景色)支持用户自己设定,图中以红色(#FF0000)为例。
通过截图可以看到,红色在这两种背景色上展示效果都还不错,主要是因为他们的对比度足够。
- 红色 vs 白色,对比度:3.998
- 红色 vs 深蓝色,对比度:5.045
要使页面的可读性得到保障,对比度至少要 > 3。
如果允许用户完全自定义前景色,就不可避免的出现用户选择的颜色和背景色的对比度 < 3,这时页面阅读起来会很费力,影响用户体验。
下面给出两种解决方案。
及时给出提示
允许用户完全自定义,意味着用户可以从色盘上选取任意颜色。当用户选取的颜色和背景色对比度 < 3 时,界面上可以给出适当提示,让用户自己决定用一个「难看的颜色」还是遵从应用的建议,选择一个对比度合理,在当前应用中可以和背景色合理搭配的颜色。
实际效果:
相关代码并没有太多难点,主要还是借助 color
库,通过 color1.contrast(color2)
获取两个颜色之间的对比度实现,这里就不放代码了。
自动计算对比度安全的颜色
另一种解决方案,就有点「强制」的意思了:在用户从色盘选色时,实时计算色值和背景色的对比度,如果 < 3 了,就使用 color.lightness()
API 逐步的调整颜色的明暗,确保最后界面上使用的是安全对比度的色值。
核心代码:
function calcLightColor(originColor: string) {
const white = Color('#fff')
let c = Color(originColor)
// 对比度 < 3,循环迭代使颜色越来越暗
while (c.contrast(white) < 3) {
// lightness() 可以读取/赋值颜色的 HSL 中的亮度值
// 如果是计算暗夜模式下的安全前景色,这里应该是 + 1,即让颜色越来越亮
c = c.lightness(c.lightness() - 1)
}
return c.hex()
}
function calcDarkColor(originColor: string() {
// ...
}
实际效果:
可以看到当用户选择了偏白的颜色时,明亮主题中实际使用的是灰色作为前景色,当用户选了择偏黑的颜色时,也有同样的自适应处理。
总结
在 2024 年的今天,CSS 看似已经足够强大,但是在颜色自适应类似的需求中还是略显不足,还好有 color
这个方便的 JavaScript 库帮助我们实现类似的需求。
无论背景色未知,还是前景色未知,只要设计界面时通过各种手段能保证前景色和背景色的对比度 > 3,那就可以保证界面的可阅读性。当然了,这里的安全对比度阈值是可以调整的,设为 3.5、3.75 都是可以的,但也非常不建议低于 3。
来源:juejin.cn/post/7407983735661936674
如何实现一个微信PC端富文本输入框?
微信PC端输入框支持图片、文件、文字、Emoji四种消息类型,本篇文章就这四种类型的消息输入进行实现。我们选用HTML5新增标签属性contenteditable
来实现。
contenteditable
属性
contenteditable
是一个全局属性,表示当前元素可编辑。
该属性拥有一下三个属性值:
- true或空字符串: 表示元素可编辑
- false: 表示元素不可编辑
- plaintext-only: 表示只有原始文本可编辑,禁用富文本编辑
给div元素添加contenteditable
, 将元素变为可编辑的状态
<div contenteditable></div>
文字输入
当鼠标聚焦在输入框内时,会有outline
展示,所以要去掉该样式
<!-- index.html -->
<div contenteditable class="editor"></div>
.editor:focus {
outline:none;
}
此时文本就可以正常输入了
图片输入
图片输入分为两部分
- 截图粘贴
- 图片文件复制粘贴
有的浏览器是直接支持截图图片的粘贴的,不过图片文件的复制粘贴就不是我们想要的效果(会把图片粘贴成一个文件的图标俺不是展示图片)。
我们这里要自己做一个图片的粘贴展示,以兼容所有的浏览器。
这里我们使用浏览器提供的粘贴事件paste
来实现
paste
事件函数中存在e.clipboardData.files
属性,该属性是一个数组,当该数组大于0时,就证明用户粘贴的是文件;通过判断 file
的 type
属性是否为image/...
来确定粘贴的内容是否为图片。
const editor = document.querySelector(".editor");
/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/
function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
editor.appendChild(img);
};
reader.readAsDataURL(imageFile);
}
// 添加paste事件
editor.addEventListener("paste", (e) => {
const files = e.clipboardData.files;
const filesLen = files.length;
console.log("files", files);
// 粘贴内用是否存在文件
if (filesLen > 0) {
//禁止默认事件
e.preventDefault();
for (let i = 0; i < filesLen; i++) {
const file = files[i];
if (file.type.indexOf("image") === 0) {
// 粘贴的文件为图片
handleImage(file);
continue;
}
// 粘贴内容是普通文件
}
}
});
现在就可以粘贴图片了,无论是截图的图片还是复制的文件图片都可以粘贴到文本框内了。
现在存在的问题就是,图片尺寸太大,会按原尺寸展示图片。微信输入框展示的尺寸为最大宽高150x150
.image {
max-width: 150px;
max-height: 150px;
}
现在图片的粘贴展示部分就是解释正常的了
细心的同学注意到了,粘贴图片后光标不会向后移动,这是因为我们禁用了粘贴的默认事件,这里需要我们手动处理光标;因为我们使用了
appendChild
将图片插入到了输入框的最后面,就会出现无论光标在哪里,粘贴的图片都是出现在输入框的最后面,我们希望的是在光标所在的地方粘贴内容,接下来我们处理一下光标。
光标处理
处理光标主要以来两个WebAPI
, Selection
和 Range
- Selection:
Selection
对象表示用户选择的文本范围或插入符号的当前位置。它代表页面中的文本选区,可能横跨多个元素。通过window.getSelection()
获取Selection
对象。 具体可查阅MDN-Selection - Range:
Range
接口表示一个包含节点与文本节点的一部分的文档片段。可以使用Document.createRange
方法创建 Range。也可以用Selection
对象的getRangeAt()
方法或者Document
对象的caretRangeFromPoint()
方法获取 Range 对象。具体可查阅MDN-Range
我们使用window.getSelection
获取当前位置,通过 getRangeAt()
获取当前选中的范围,将选中的内容删除,再把我们自己的内容插入到当前的 Range
中,就实现了文件的输入。在这时插入的文件是选中状态的,所以要将选择范围折叠到结束位置,即在粘贴文本的后面显示光标。
/**
* 将指定节点插入到光标位置
* @param {DOM} fileDom dom节点
*/
function insertNode(dom) {
// 获取光标
const selection = window.getSelection();
// 获取选中的内容
const range = selection.getRangeAt(0);
// 删除选中的内容
range.deleteContents();
// 将节点插入范围最前面添加节点
range.insertNode(dom);
// 将光标移到选中范围的最后面
selection.collapseToEnd();
}
/**
* 处理粘贴图片
* @param {File} imageFile 图片文件
*/
function handleImage(imageFile) {
// 创建文件读取器
const reader = new FileReader();
// 读取完成
reader.onload = (e) => {
// 创建img标签
const img = new Image();
img.src = e.target.result;
- editor.appendChild(img);
+ insertNode(img);
};
reader.readAsDataURL(imageFile);
}
到这里粘贴到光标位置就正常了
文件输入
文件输入分为两部分粘贴文件和选择文件
- 粘贴文件
- 选择文件
粘贴文件
微信是以卡片的方式展示的文件,所以我们先要准备一个类似的dom结构。
/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/
function createFileDom(file) {
const size = file.size / 1024;
const templte = `
<div class="file-container">
<img src="./assets/PDF.png" class="image" />
<div class="title">
<span>${file.name || "未命名文件"}</span>
<span>${size.toFixed(1)}K</span>
</div>
</div>`;
const dom = document.createElement("span");
dom.style = "display:flex;";
dom.innerHTML = templte;
return dom;
}
.file-container {
height: 75px;
width: 405px;
box-sizing: border-box;
border: 1px solid rgb(208, 208, 208);
padding: 13px;
display: flex;
justify-content: start;
gap: 13px;
background-color: #ffffff;
}
.file-container .image {
height: 100%;
object-fit: scale-down;
}
.file-container .title {
display: flex;
flex-direction: column;
gap: 2px;
}
按照微信样式卡片准备DOM的生成函数,如上。
将生成后的DOM加入到光标位置
/**
* 处理粘贴文件
* @param {File} file 文件
*/
function handleFile(file) {
insertNode(createFileDom(file));
}
在页面上就可以看到效果啦
可以看到以上的卡片还是存在问题的,
- 光标位置不在卡片的最后面
Backspace
不会将整个卡片都删除掉
卡片的删除
因为我们的卡片是DOM,其父元素也就是输入框,开启了contenteditable
,因此它的该属性也继承了父元素的值,所以本身也是可以编辑的。分析到这里,就会涌现出一个方案——将卡片最外层 div
的contenteditable
属性值设为 false
。
但是,设置了 contenteditable="false"
后就变成了不可编辑元素,光标在他周围就不显示了🥹🥹🥹
再接着考虑,思绪一下又回到了图片身上,我们能不能做一个一样的图片呢?答案是肯定的。
说到做图片我们又会有两种方案:
- svg
- canvas
这里就考虑使用SVG制作卡片。
SVG在这里就不多介绍了,直接开始
<svg width="409" height="77" nsxml="http://www.w3.org/2000/svg">
<!-- 矩形边框 -->
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>
<!-- 右侧文件图标 -->
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>
<!-- 图标折叠三角部分 -->
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<!-- 文件类型 -->
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>
xmind
</text>
<!-- 文件名称 -->
<text x="63" y="30" font-size="16">JavaScript.xmind</text>
<!-- 文件大小 -->
<text x="63" y="52" font-size="14">206.6K</text>
</svg>
然后就得到了一个卡片
现在我们只要将该卡片使用JS封装,在上传文件的时候调用生成图片就好了。
封装如下:
/**
* 返回文件卡片的DOM
* @param {File} file 文件
* @returns 返回dom结构
*/
function createFileDom(file) {
const size = file.size / 1024;
let extension = "未知文件";
if (file.name.indexOf(".") >= 0) {
const fileName = file.name.split(".");
extension = fileName.pop();
}
const templte = `
<rect
x="2"
y="0"
width="405"
height="75"
fill="none"
stroke="rgb(208,208,208)"
/>
<polygon
points="15 13,36 13,50 30,50 60,15 60"
fill="rgb(156,193,233)"
stroke="rgb(0,105,255)"
stroke-width="2"
/>
<polygon points="36 13,36 30,50 30" fill="rgb(0,105,255)" />
<text
x="32"
y="50"
font-size="10"
text-anchor="middle"
fill="rgb(0,105,255)"
>
${extension}
</text>
<text x="63" y="30" font-size="16">${file.name || "未命名文件"}</text>
<text x="63" y="52" font-size="14">${size.toFixed(1)}K</text>
`;
const dom = document.createElementNS("http://www.w3.org/2000/svg", "svg");
dom.setAttribute("width", "409");
dom.setAttribute("height", "77");
dom.innerHTML = templte;
const svg = new XMLSerializer().serializeToString(dom);
const blob = new Blob([svg], {
type: "image/svg+xml;charset=utf-8",
});
const imageUrl = URL.createObjectURL(blob);
const img = new Image();
img.src = imageUrl;
return img;
}
到这里我们的文件粘贴就完成了,来展示
上传文件
上传文件又包含两部分:图片和普通文件
我们需要在这里做一个判断,如果是图片需要将其内容渲染出来,文件用卡片显示。
<div class="controls">
<img src="./assets/emoji.png" alt="表情" title="表情" />
<img
src="./assets/file.png"
alt="发送文件"
title="发送文件"
class="file"
/>
</div>
const fileDom = document.querySelector(".file");
fileDom.addEventListener("click", () => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});
以上代码,在输入框上方添加了一个文件的icon
, 给这个icon
添加点击事件,选择文件并展示。
到这里文件上传就做好了,但是还是存在问题的。
输入框的光标,在鼠标点击页面其他地方的时候会从输入框移出,如果在这时选择文件不会添加到输入框中。
所以,我们在点击文件上传的时候需要叫光标聚焦到输入框。
const fileDom = document.querySelector(".file");
fileDom.addEventListener("click", () => {
+ // 光标聚焦到输入框
+ editor.focus();
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("multiple", "true");
input.addEventListener("change", () => {
const files = input.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
fileTypeCheck(file);
}
});
input.click();
});
到这里文件上传就完美了。
总结
以上的源码已经上传到了Github, 想要源码的小伙伴自己去拉。代码读取文件那部分可以使用Promise
进行优化。最后,欢迎大佬批评指正。
来源:juejin.cn/post/7312848658718375971
没有后端,前端也能下载文件
📋 功能介绍
- 纯前端,不涉及后端或接口
- 点击下载按钮,下载一个html文件(任何文件都可以实现),打开下载的文件展示一个的html网页
📽️ 演示Treasure-Navigation
💡思路
- 编写对应的字符串信息
- 把字符串信息转成url,赋值给a标签
- a标签设置download属性后,可以下载url中的内容信息
代码实现
- 文本信息可以随意写,最终下载的内容就是下方文本
// 定义一个包含 HTML 内容的字符串
const htmlStr = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导航集合</title>
</head>
...... // 此处省略业务代码,有兴趣可以去我的项目中查看
<\/html>
`;
1. URL.createObjectURL下载
// 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// blob转成url,使用URL.createObjectURL和fileReader都可以
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBolo);
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = htmlStrUrl;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 触发链接的点击事件,开始下载
aLink.click();
// 释放之前创建的 URL 对象,释放内存
URL.revokeObjectURL(htmlStrUrl);
上面是最推荐一种方式。使用URL.createObjectURL,然后立即手动释放内存URL.revokeObjectURL(htmlStrUrl)性能高效。
2. FileReader.readAsDataURL下载
// 创建一个 Blob 对象,将 HTML 字符串封装成一个可下载的文件
const htmlStrBolo = new Blob([htmlStr], { type: 'text/html' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = (e) => {
// 创建一个链接元素
const aLink = document.createElement('a');
// 设置链接的 href 属性为刚刚生成的 URL
aLink.href = reader.result;
// 设置下载文件的名称
aLink.download = '下载文件名称.html';
// 生成的base64编码
aLink.click();
};
🌟理解URL.createObjectURL和FileReader.readAsDataURL
在web中处理文件和数据是常见的需求。URL.createObjectURL() 和 FileReader.readAsDataURL() 是两个用于处理文件对象的常用方法。他们都是将 File 或 Blob 对象转换成URL的形式。让我们来深入了解它们的用途和区别。
📄FileReader.readAsDataURL
- 功能概述
URL.createObjectURL(myBlob) 可以将 File 或 Blob 对象转换为临时 URL。只要没有销毁,该临时 URL 可以在任何网页中使用,网页将显示对应的 File 或 Blob 信息。 - 生命周期
该 URL 的生命周期与其创建时所在窗口的 document 绑定在一起。一旦关闭原窗口,该临时 URL 将失效。 - 内存管理
可以使用 URL.revokeObjectURL(myUrl) 提前销毁该 URL,以释放内存。
- 代码示例
// 创建blob信息
const htmlStrBlob = new Blob(["Hello World"], { type: 'text/plain' });
// 创建一个指向 Blob 对象的 URL
const htmlStrUrl = URL.createObjectURL(htmlStrBlob);
console.log(htmlStrUrl);
//在执行 revokeObjectURL 之前,htmlStrUrl可以复制到浏览器的地址栏中
URL.revokeObjectURL(htmlStrUrl);
📄FileReader.readAsDataURL
- 功能概述
FileReader.readAsDataURL() 方法用于将 File 或 Blob 对象读取为一个 Base64 编码的字符串。该字符串可以在任意网页中永久使用,网页将显示对应的 File 或 Blob 信息。 - 生命周期
生成的 Base64 字符串的生命周期是永恒的。
- 代码示例
// 创建一个 Blob 信息
const htmlStrBolo = new Blob(["Hello World"], { type: 'text/plain' });
// 创建FileReader,可以转换文件格式
const reader = new FileReader()
// 传入被读取的blob对象,并且转换成url形式
reader.readAsDataURL(htmlStrBolo)
reader.onload = () => {
// 这个reader.result可以复制到浏览器的地址栏中,永远可以查看对应的信息
console.log(reader.result)
}
⚖️ 区别总结
1.生成的 URL 类型
- URL.createObjectURL(): 生成一个临时的对象 URL。
- FileReader.readAsDataURL(): 生成一个 Base64 编码的数据 URL,相对临时的URL会大很多
2.性能
- URL.createObjectURL(): 性能更好,因为不需要将文件内容加载到内存中,可以使用完后立即销毁。
- FileReader.readAsDataURL(): 可能会占用更多内存,特别是在处理大文件时。
来源:juejin.cn/post/7425656340982480936
写给开发者的Material Design3
M3是Google开源的一套设计规范,主要是UI/UX设计人员设计APP的重要参考依据,与Material相比设计人员可能更偏爱苹果的设计规范,至少我认识的朋友是这样。
关于Material You的疑惑
M3还有一个名字叫Material You,至于区别ChatGPT是这样说的
Material You 是 Material Design 3 (M3) 的市场化和用户友好的名称,而 M3 则是更技术和官方的术语,两者指的是同一个设计系统,只是表达方式不同。
我推测Google为了彰显新版本的高大上,一开始起了高大上的名字Material You,后来发现还有可能发新版本,叫卖油就不妥了,又改回一贯的风格就又叫M3了。所以,现在M3官网上名字都是Material 3,偶尔在看Android文档是会有Material You,以后再看到这两个名字请不要疑惑。
一些其它的设计规范
有很多组件库遵顼Material的设计规范,像Android UI库的风格默认就是Material、还有Flutter、Web都有遵循这套设计规范的UI组件库。国内的Ant Design应该也是参考M3搞得,一开始只有Web端的React组件可以用,后来社区支持了Vue组件。最近看到字节也出了一套设计规范叫Arco Design,大家要知道这是一类xx。
Material 3
M3主要由三大部分组成:
- Foundations:规范的基础部分,包含一些核心原则。Foundations部分上来就强调了Accessibility,也就是从设计角度要尽量要满足每个人,包含正常人和残疾人(失明、听力障碍、认知障碍、情景障碍如手臂骨折)。这应是设计师的底限,却是很多APP的上限。尤其是一些单位外包APP,如某电网、某银行和一些基层单位的信息化工具,钱没少挣,设计么呵呵。以对比度为例,对于不同的字号以及图片都有不同的规范。此外还有布局规范、交互规范、Token、自定义等。
- Styles:Styles部分包含颜色、层、图标、动画、图形、排版,在 Foundations 之上应用的视觉风格,定义如何将基础元素组合和调整,以实现特定的品牌和界面风格。
- Components:使用 Foundations 和 Styles 构建的可复用 UI 元素,它们是设计和开发中实际使用的界面模块。例如:按钮、输入框、导航栏等。
关于Android UI组件库
Android的两套UI组件库,Compose和基于XML写法的组件库都实现了M3规范,需要区分一下M3中的Components和Compose中的Component。我感觉后者是前者的超集,因为Compose的代码中包含了很多在M3中没有的组件,例如Scaffold。细细想来应该是Android开发团队为了方便应用开发者快速开发,依照M3规范扩充了UI组件库中的组件。
基于XML写法的组件库是旧版本的,但是目前使用人数还是最多的。估计是切换的到新版Compose写法收益不大,另外也可能是Android开发者圈子相对前端活跃度低。换做前端圈子,早就卷起来了。我没经历过大厂,记忆中16年我就听说过React,到19年React就已经非常流行了。而Jetpack Compose都多少年了,还是半死不过。我在14年还在上大专时,接触过Android开发,那时还是使用XML定义UI,感觉极其难用,后来就没有接触了,时隔10年,Android使用Compose的开发体验已经相当开门了,然鹅这玩意为啥不火呢?而且JB让这个技术扩展到了PC端,甚至Web端,应该是值得学习的吧,难道JB还不够硬。。还是gg不够硬啊
来源:juejin.cn/post/7432866688365740041
页面跳转如何优雅携带大数据Array或Object
前言
- 在小程序或者app开发中,最常用到的就是页面跳转,上文中详细介绍了页面跳转4种方法的区别和使用,可以点击查看👉分析小程序页面导航与事件通讯。
- 而页面跳转就经常会携带数据到下一个页面,常见的做法是通过 URL 参数将数据拼接在
navigateTo
的 URL 后面。然而,这种方式在处理较大数据(如数组或对象)时会显得笨拙且有限。 - 下面将讨论通过 URL 传递参数的局限性,以及使用
EventChannel
进行数据传递的好处,并提供代码示例进行解析。
一、使用 URL 参数传递数据的局限性
在小程序中,我们通常使用 navigateTo
方法来跳转到另一个页面,并通过 URL 传递参数。例如:
// 使用 URL 参数进行页面跳转
wx.navigateTo({
url: '/pages/target/target?name=John&age=30'
});
1.1 问题
- 数据大小限制:URL 的长度限制通常在 2000 字符左右。如果需要传递一个较大的数据结构(例如一个包含大量信息的对象或数组),URL 会迅速达到限制,导致无法完整传递数据。
- 编码和解析:当数据包含特殊字符(如空格、&、=等)时,必须进行编码处理。这增加了编码和解析的复杂性,不够直观。
- 可读性差:长的 URL 会导致可读性降低,特别是当需要传递多个参数时,容易让人困惑。
二、使用 EventChannel 的优势
相比之下,使用 EventChannel
可以更优雅地在页面之间传递数据,尤其是复杂的数据结构。以下是使用 EventChannel
的几个主要优点:
- 支持复杂数据类型:可以直接传递对象和数组,无需担心 URL 长度限制或特殊字符的编码问题。
- 简化代码:代码更简洁,逻辑更清晰,特别是在需要传递多个参数时。
- 即时通信:在新页面创建后,可以立即接收数据,使得页面间的交互更加流畅。
三、示例代码解析
3.1 在源页面中
在源页面中,我们可以使用 EventChannel
传递数组和对象:
// 源页面
wx.navigateTo({
url: '/pages/target/target',
events: {
// 监听目标页面发送的消息
someEvent(data) {
console.log(data); // 可以在这里处理目标页面返回的数据
}
},
success: (res) => {
// 创建要传递的复杂数据
const arrayData = [1, 2, 3, 4, 5];
const objectData = { key: 'value', info: { nestedKey: 'nestedValue' } };
// 通过 EventChannel 向目标页面传递数据
res.eventChannel.emit('someEvent', {
array: arrayData,
object: objectData,
});
}
});
3.2 在目标页面中
在目标页面中,我们接收并使用传递的数据:
// 目标页面
const eventChannel = this.getOpenerEventChannel();
eventChannel.on('someEvent', (data) => {
// 接收数组和对象数据
console.log(data.array); // [1, 2, 3, 4, 5]
console.log(data.object); // { key: 'value', info: { nestedKey: 'nestedValue' } }
// 进行相应的数据处理
// 例如,渲染数据到页面上
this.setData({
receivedArray: data.array,
receivedObject: data.object,
});
});
四、总结
通过使用 EventChannel
进行页面间的数据传递,我们可以避免使用 URL 传递参数时面临的各种局限性,特别是在处理复杂数据时。EventChannel
使得数据传递变得更加灵活、简洁和高效,提升了小程序的用户体验。
在实际开发中,传递较少数据时,可以在url后面拼接参数进行传递使用。当需要携带大数据时可以考虑使用 EventChannel
进行复杂数据的传递,确保应用的交互更加顺畅。
来源:juejin.cn/post/7433271555830431784
2024年大龄程序员不得不面对的尴尬,也算是过来人的经验
被裁员
先说下本人情况,38,坐标杭州,具备后端架构和大数据架构能力。待过大厂,带过团队,落地过大型项目。
近几年被裁员也不算什么稀奇古怪的事情,我记得2022年下半年面试那会行情远比现在好,那会还会有猎头、企业主动找,我2022年的工作就是猎头推进去的。
然而公司运营不善,2023年底裁撤了,因为融资问题,被迫出局。
本想着年后再看工作,也想休息一段时间,于是年前就没理工作这个事。
狗不理
因为信息差,也可能因为行业这种趋势本身就是没法感知的,年后投简历发现了几个情况:
- 无论是猎聘、BOSS、智联,好像岗位都比之前少了很多,并且很多都是钉子户,我2022年找工作那会看他们挂的JD,2024年了仍然还在。
- 很多JD都要求的时间就在两个段,一个是1—3年,一个是5—10年。那么从23岁毕业来看,现在只要33岁以下的了。
- 从跟一些猎头的沟通来看,现在很多好点的岗位是需要本硕985,211都不一定看了,并且很多事明确要求硕士。这其实一刀切,放十几年前,考大学比现在难。
- 很多简历发出去直接被拒,要么是未读。基本上已经有去无回了。
一些感悟
面对这种突如其来的颠覆认知的行情,我有点措手不及,没预想自己可能就此失业了。现在的世界变化太快了,也可能我待在舒适区太久了,根本对外部世界的变化钝感迟缓。
我也没去问招聘方是什么原因,本身就个人从业经历和技能能来说,自认为还OK,但是问人家也未必会告诉你实话,在这个存量市场,势必是僧多肉少,刺刀见红,现实残酷,朝不保夕。
但是反思下十几年的职场生涯,其实多多少少还是有个人原因的,总结出来,留给后来人吧:
- 不要迷信技术,我以前以为只要技术好就是核心竞争力,就有护城河。现在发现这种信仰只有在一个崇尚技术、尊重技术的环境中才有效。但是目前看下,这个环境越来越不是,今后肯定是越来越人情社会,越来越丛林化。所以,得有综合全面的“技术”才能混,至于我说的综合全面,懂的都懂。
- IT行业不适合深耕,只适合扩展。就是你得把他当工具,千万别代入,不然你会陷入很深的工具人思维。就是你总得想着有朝一日假如你被裁员了,你是否有利用这个工具单干的能力。尤其是现在AI技术日新月异,很有可能程序员一夜之间就变成土木。
- 一个要打造个人IP,要清醒地培养自己独立赚钱的能力,跳出自己的舒适区。很可能你目前的舒适生活是由行业或平台的红利带来的,你也很可能就是那个被圈养的巨婴。想想《肖申克的救赎》那几个经典片段:坐牢越久越是离不开监狱的,到最后极度依赖,没有自己。
- 认知越早扭转越好,不要等到35岁别人不要你了才幡然醒悟,我就是反例,到这个时候怀着空杯心态再从零开始,代价不得不说有点太大了。
个人期望
最后说点自己的个人期望和未深思的规划:
1、后面还是要自己单干的,可以是独立开发、或者其他。
2、还是会热爱技术,即使如果干不了这行了,也会把它当做一个兴趣培养。
来源:juejin.cn/post/7343902139821785124
简易聊天机器人设计
1. 引言
Spring AI Alibaba 开源项目基于 Spring AI 构建,是阿里云通义系列模型及服务在 Java AI 应用开发领域的最佳实践,提供高层次的 AI API 抽象与云原生基础设施集成方案,帮助开发者快速构建 AI 应用。
2. 效果展示
源代码地址 DailySmileStart/simple-chatboot (gitee.com)
3. 代码实现
依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
注意:由于 spring-ai 相关依赖包还没有发布到中央仓库,如出现 spring-ai-core 等相关依赖解析问题,请在您项目的 pom.xml 依赖中加入如下仓库配置。
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url><https://repo.spring.io/milestone></url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
@SpringBootApplication
public class SimpleChatbootApplication {
public static void main(String[] args) {
SpringApplication.run(SimpleChatbootApplication.class, args);
}
}
配置自定义ChatClient
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatClientConfig {
static ChatMemory chatMemory = new InMemoryChatMemory();
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
}
controller类
import ch.qos.logback.core.util.StringUtil;
import com.hbduck.simplechatboot.demos.function.WeatherService;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.UUID;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
@RestController
@RequestMapping("/ai")
public class ChatModelController {
private final ChatModel chatModel;
private final ChatClient chatClient;
public ChatModelController(ChatModel chatModel, ChatClient chatClient) {
this.chatClient = chatClient;
this.chatModel = chatModel;
}
@GetMapping("/stream")
public String stream(String input) {
StringBuilder res = new StringBuilder();
Flux<ChatResponse> stream = chatModel.stream(new Prompt(input));
stream.toStream().toList().forEach(resp -> {
res.append(resp.getResult().getOutput().getContent());
});
return res.toString();
}
@GetMapping(value = "/memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> memory(@RequestParam("conversantId") String conversantId, @RequestParam("input") String input) {
if (StringUtil.isNullOrEmpty(conversantId)) {
conversantId = UUID.randomUUID().toString();
}
String finalConversantId = conversantId;
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
return Flux.concat(
// First event: send conversationId
Flux.just(ServerSentEvent.<String>builder()
.event("conversationId")
.data(finalConversantId)
.build()),
// Subsequent events: send message content
chatResponseFlux.map(response -> ServerSentEvent.<String>builder()
.id(UUID.randomUUID().toString())
.event("message")
.data(response.getResult().getOutput().getContent())
.build())
);
}
}
配置文件
server:
port: 8000
spring:
thymeleaf:
cache: true
check-template: true
check-template-location: true
content-type: text/html
enabled: true
encoding: UTF-8
excluded-view-names: ''
mode: HTML5
prefix: classpath:/templates/
suffix: .html
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
client:
enabled: false
前端页面
<!DOCTYPE html>
<html>
<head>
<title>AI Chat Bot</title>
<style>
#chatBox {
height: 400px;
border: 1px solid #ccc;
overflow-y: auto;
margin-bottom: 10px;
padding: 10px;
}
.message {
margin: 5px;
padding: 5px;
}
.user-message {
background-color: #e3f2fd;
text-align: right;
}
.bot-message {
background-color: #f5f5f5;
white-space: pre-wrap; /* 保留换行和空格 */
word-wrap: break-word; /* 长单词换行 */
}
</style>
</head>
<body>
<h1>AI Chat Bot</h1>
<div id="chatBox"></div>
<input type="text" id="userInput" placeholder="Type your message..." style="width: 80%">
<button onclick="sendMessage()">Send</button>
<script>
let conversationId = null;
let currentMessageDiv = null;
function addMessage(message, isUser) {
const chatBox = document.getElementById('chatBox');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user-message' : 'bot-message'}`;
messageDiv.textContent = message;
chatBox.appendChild(messageDiv);
chatBox.scrollTop = chatBox.scrollHeight;
return messageDiv;
}
async function sendMessage() {
const input = document.getElementById('userInput');
const message = input.value.trim();
if (message) {
addMessage(message, true);
input.value = '';
// Create bot message container
currentMessageDiv = addMessage('', false);
const eventSource = new EventSource(`/ai/memory?conversantId=${conversationId || ''}&input=${encodeURIComponent(message)}`);
eventSource.onmessage = function(event) {
const content = event.data;
if (currentMessageDiv) {
currentMessageDiv.textContent += content;
}
};
eventSource.addEventListener('conversationId', function(event) {
if (!conversationId) {
conversationId = event.data;
}
});
eventSource.onerror = function(error) {
console.error('SSE Error:', error);
eventSource.close();
if (currentMessageDiv && currentMessageDiv.textContent === '') {
currentMessageDiv.textContent = 'Sorry, something went wrong!';
}
};
// Close the connection when the response is complete
eventSource.addEventListener('complete', function(event) {
eventSource.close();
currentMessageDiv = null;
});
}
}
// Allow sending message with Enter key
document.getElementById('userInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
带chat memory 的对话
可以使用 InMemoryChatMemory实现
//初始化InMemoryChatMemory
static ChatMemory chatMemory = new InMemoryChatMemory();
//在ChatClient 配置memory
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
.build();
}
//调用时配置
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
工具
“工具(Tool)”或“功能调用(Function Calling)”允许大型语言模型(LLM)在必要时调用一个或多个可用的工具,这些工具通常由开发者定义。工具可以是任何东西:网页搜索、对外部 API 的调用,或特定代码的执行等。LLM 本身不能实际调用工具;相反,它们会在响应中表达调用特定工具的意图(而不是以纯文本回应)。然后,我们应用程序应该执行这个工具,并报告工具执行的结果给模型。
通过工具来实现获取当前天气
天气获取的类,目前使用硬编码温度
import com.fasterxml.jackson.annotation.JsonClassDescription;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.hbduck.simplechatboot.demos.entity.Response;
import java.util.function.Function;
public class WeatherService implements Function<WeatherService.Request, Response> {
@Override
public Response apply(Request request) {
if (request.city().contains("杭州")) {
return new Response(String.format("%s%s晴转多云, 气温32摄氏度。", request.date(), request.city()));
}
else if (request.city().contains("上海")) {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
else {
return new Response(String.format("%s%s多云转阴, 气温31摄氏度。", request.date(), request.city()));
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonClassDescription("根据日期和城市查询天气")
public record Request(
@JsonProperty(required = true, value = "city") @JsonPropertyDescription("城市, 比如杭州") String city,
@JsonProperty(required = true, value = "date") @JsonPropertyDescription("日期, 比如2024-08-22") String date) {
}
}
chatClient配置function
Flux<ChatResponse> chatResponseFlux = chatClient
.prompt()
.function("getWeather", "根据城市查询天气", new WeatherService())
.user(input)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, finalConversantId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream().chatResponse();
来源:juejin.cn/post/7436369701020516363
绑定大量的的v-model,导致页面卡顿的解决方案
绑定大量的的v-model,导致页面卡顿的解决方案
设计图如下:
页面布局看着很简单使用element组件,那就完蛋了,因为是大量的数据双向绑定,所以使用组件,延迟非常高,高到什么程度,请求100条数据到渲染到页面上,要10-12s,特别是下拉选择的时候,延迟都在2-3s,人麻了老铁!!!
卡顿的原因很长一段时间都是在绑定v-model,为什么绑定v-model会很卡呢,请求到的每一条数据有14个数据需要绑定v-model,每次一请求就是100个打底,那就是1400个数据需要绑定v-model;而且组件本身也有延迟,所以这个方案不能采用,那怎么做呢?
我尝试采用原生去写,写着写着,哎解决了!!!惊呆了
做完后100条数据页面渲染不超过2s,毕竟还是需要绑定v-model,能在2s内,我还是能接受的吧;选择和输入延迟基本没有
下面就来展示一下我的代码,写的不好看着玩儿就好了:
请求到的数据:
methods这两个事件做的什么事儿呢,就是手动将数据绑定到数据上去也就是row上如图:
当然还有很多解决方案
来源:juejin.cn/post/7392248233222881316
使用wxpython开发跨平台桌面应用,对WebAPI调用接口的封装
我在前面介绍的系统界面功能,包括菜单工具栏、业务表的数据,开始的时候,都是基于模拟的数据进行测试,数据采用JSON格式处理,通过辅助类的方式模拟实现数据的加载及处理,这在开发初期是一个比较好的测试方式,不过实际业务的数据肯定是来自后端,包括本地数据库,SqlServer、Mysql、Oracle、Sqlite、PostgreSQL等,或者后端的WebAPI接口获取,本篇随笔逐步介绍如何对后端的数据接口进行建模以及提供本地WebAPI代理接口类的处理过程。
1、定义Web API接口类并测试API调用基类
我在随笔《使用wxpython开发跨平台桌面应用,动态工具的创建处理》中介绍了关于工具栏和菜单栏的数据类,以及模拟方式获得数据进行展示,如下界面所示。
如菜单数据的类信息,如下所示。
class MenuInfo:
id: str # 菜单ID
pid: str # 父菜单ID
label: str # 菜单名称
icon: str = # 菜单图标
path: str = # 菜单路径,用来定位视图
tips: str = # 菜单提示
children: list["MenuInfo"] =
这些数据和后端数据接口的定义一致,那么就很容易切换到动态的接口上。
在系统开发的初期,我们可以先试用模拟方式获得数据集合,如通过一个工具来来获得数据,如下所示。
为了更好的符合实际的业务需求,我们往往需要根据服务端的接口定义来定义调用Web API接口的信息。
我们为了全部采用Python语言进行开发,包括后端的内容,采用 基于SqlAlchemy+Pydantic+FastApi 的后端框架
该后端接口采用统一的接口协议,标准协议如下所示。
{
"success": false,
"result": T , "targetUrl": "string",
"UnAuthorizedRequest": false,
"errorInfo": {
"code": 0,
"message": "string",
"details": "string"
}
}
其中的result是我们的数据返回,有可能是基本类型(如字符串、数值、布尔型等),也有可能是类集合,对象信息,字典信息等等。
如果是分页查询返回结果集合,其结果如下所示。
展开单条记录明细如下所示。
如果我们基于Pydantic模型定义,我们的Python对象类定义代码如下所示
from pydantic import BaseModel
from typing import Generic, Type, TypeVar, Optional
T = TypeVar("T")
# 自定义返回模型-统一返回结果
class AjaxResponse(BaseModel, Generic[T]):
success: bool = False
result: Optional[T] =
targetUrl: Optional[str] =
UnAuthorizedRequest: Optional[bool] = False
errorInfo: Optional[ErrorInfo] =
也就是结合泛型的方式,这样定义可以很好的抽象不同的业务类接口到基类BaseApi中,这样增删改查等处理的接口都可以抽象到BaseApi里面了。
权限模块我们涉及到的用户管理、机构管理、角色管理、菜单管理、功能管理、操作日志、登录日志等业务类,那么这些类继承BaseApi,就会具有相关的接口了,如下所示继承关系。
2、对异步调用进行测试和接口封装
为了理解客户端Api类的处理,我们先来介绍一些简单的pydantic 入门处理,如下我们先定义一些实体类用来承载数据信息,如下所示。
from typing import List, TypeVar, Optional, Generic, Dict, Any
from datetime import datetime
from pydantic import BaseModel, Field
T = TypeVar("T")
class AjaxResult(BaseModel, Generic[T]):
"""测试统一接口返回格式"""
success: bool = True
message: Optional[str] =
result: Optional[T] =
class PagedResult(BaseModel, Generic[T]):
"""分页查询结果"""
total: int
items: List[T]
class Customer(BaseModel):
"""客户信息类"""
name: str
age: int
一般业务的结果是对应的记录列表,或者实体类对象格式,我们先来测试解析下它们的JSON数据,有助于我们理解。
# 对返回结果数据格式的处理
json_data = """{
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}"""
paged_result = PagedResult.model_validate_json(json_data)
print(paged_result.total)
print(paged_result.items)
以上正常解析到数据,输出结果如下所示。
100
[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
True
如果我们换为统一返回的结果进行测试,如下所示。
json_data = """{
"success": true,
"message": "success",
"result": {
"total": 100,
"items": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}
}"""
ajax_result = AjaxResult[PagedResult].model_validate_json(json_data)
print(ajax_result.success)
print(ajax_result.message)
print(ajax_result.result.total)
print(ajax_result.result.items)
同样的可以获得正常的输出。
True
success
100
[{'name': 'Alice', 'age': 25}, {'name': 'Bob', 'age': 30}, {'name': 'Charlie', 'age': 35}]
我们通过 model_validate_json 接口可以转换字符串内容为对应的业务类对象,而通过 model_validate 函数可以转换JSON格式为业务类对象。
而对于接口的继承处理,我们采用了泛型的处理,可以极大的减少基类代码的编写,如下基类定义和子类定义,就可以简单很多,所有逻辑放在基类处理即可。
class BaseApi(Generic[T]):
def test(self) -> AjaxResult[Dict[str, Any]]:
json_data = """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}"""
result = AjaxResult[Dict[str, Any]].model_validate_json(json_data)
return result
def get(self, id: int) -> AjaxResult[T]:
json_data = """{
"success": true,
"message": "success",
"result": {"name": "Alice", "age": 25}
}"""
result = AjaxResult[T].model_validate_json(json_data)
return result
def getlist(self) -> AjaxResult[List[T]]:
json_data = """{
"success": true,
"message": "success",
"result": [
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 30},
{"name": "Charlie", "age": 35}
]
}"""
result = AjaxResult[List[T]].model_validate_json(json_data)
return result
class UserApi(BaseApi[Customer]): pass
user_api = UserApi()
result = user_api.getlist()
print(result.success)
print(result.message)
print(result.result)
result = user_api.get(1)
print(result.success)
print(result.message)
print(result.result)
result = user_api.test()
print(result.success)
print(result.message)
print(result.result)
可以看到,子类只需要明确好继承关系即可,不需要编写任何多余的代码,但是又有了具体的接口处理。
3、实际HTTTP请求的封装处理
一般对于服务端接口的处理,我们可能需要引入 aiohttp 来处理请求,并结合Pydantic的模型处理,是的数据能够正常的转换,和上面的处理方式一样。
首先我们需要定义一个通用HTTP请求的类来处理常规的HTTP接口数据的返回,如下所示。
class ApiClient:
_access_token = # 类变量,用于全局共享 access_token
@classmethod
def set_access_token(cls, token):
"""设置全局 access_token"""
cls._access_token = token
@classmethod
def get_access_token(cls):
"""获取全局 access_token"""
return cls._access_token
def _get_headers(self):
headers = {}
if self.get_access_token():
headers["Authorization"] = f"Bearer {self.get_access_token()}"
return headers
async def get(self, url, params=):
async with aiohttp.ClientSession() as session:
async with session.get(
url, headers=self._get_headers(), params=params
) as response:
return await self._handle_response(response)
async def post(self, url, json_data=):
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers=self._get_headers(), json=json_data
) as response:
return await self._handle_response(response)
async def put(self, url, json_data=):
async with aiohttp.ClientSession() as session:
async with session.put(
url, headers=self._get_headers(), json=json_data
) as response:
return await self._handle_response(response)
async def delete(self, url, params=):
async with aiohttp.ClientSession() as session:
async with session.delete(
url, headers=self._get_headers(), params=params
) as response:
return await self._handle_response(response)
async def _handle_response(self, response):
if response.status == 200:
return await response.json()
else:
response.raise_for_status()
这些我来基于通用ApiClient的辅助类,对业务接口的调用进行一个简单基类的封装,命名为BaseApi,接受泛型类型定义,如下所示。
class BaseApi(Generic[T]):
base_url = "http://jsonplaceholder.typicode.com/"
client: ApiClient = ApiClient()
async def getall(self, endpoint, params=) -> List[T]:
url = f"{self.base_url}{endpoint}"
json_data = await self.client.get(url, params=params)
# print(json_data)
return list[T](json_data)
async def get(self, endpoint, id) -> T:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.get(url)
# return parse_obj_as(T,json_data)
adapter = TypeAdapter(T)
return adapter.validate_python(json_data)
async def create(self, endpoint, data) -> bool:
url = f"{self.base_url}{endpoint}"
await self.client.post(url, data)
return True
async def update(self, endpoint, id, data) -> T:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.put(url, data)
adapter = TypeAdapter(T)
return adapter.validate_python(json_data)
async def delete(self, endpoint, id) -> bool:
url = f"{self.base_url}{endpoint}/{id}"
json_data = await self.client.delete(url)
# print(json_data)
return True
我这里使用了一个 测试API接口很好的网站:jsonplaceholder.typicode.com/,它提供了很多不同业务对象的接口信息,如下所示。
统一提供GET/POST/PUT/DELETE等常规Restful动作的处理
如我们获取列表数据的接口如下,返回对应的JSON集合。
通过对应的业务对象不同的动作处理,我们可以测试各种接口。
注意,我们上面的接口都是采用了async/awati的对应异步标识来处理异步的HTTP接口请求。
上面我们定义了BaseApi,具有常规的getall/get/create/update/delete的接口,实际开发的时候,这些会根据后端接口请求扩展更多基类接口。
基于基类BaseApi定义,我们创建其子类PostApi,用来获得具体的对象定义接口。
class PostApi(BaseApi[post]):
# 该业务接口类,具有基类所有的接口
# 并增加一个自定义的接口
async def test(self) -> Db:
url = "http://my-json-server.typicode.com/typicode/demo/db"
json_data = await self.client.get(url)
# print(json_data)
return Db.model_validate(json_data)
这里PostApi 具有基类所有的接口:getall/get/create/update/delete的接口, 并可以根据实际情况增加自定义接口,如test 接口定义。
测试代码如下所示。
async def main(): post_api = PostApi()
result = await post_api.getall("posts")
print(len(result))
result = await post_api.get("posts", 1)
print(result)
result = await post_api.create(
"posts", {"title": "test", "body": "test body", "userId": 1}
)
print(result)
result = await post_api.update(
"posts", 1, {"title": "test2", "body": "test body2", "userId": 1, "id": 1}
)
print(result)
result = await post_api.delete("posts", 1)
print(result)
result = await post_api.test()
print(result)
if __name__ == "__main__":
asyncio.run(main())
运行例子,输出如下结果。
来源:juejin.cn/post/7436524595290849289
微前端原理与iframe 项目实践
一、背景
在讲微前端之前,首先了解下前端开发模式的发展历程,在最早的时候,前端的开发是耦合在服务端,主要的工作其实就是提供一个界面模板,交互也不多,实际的数据还是在服务端渲染的时候提供的。
大概在2010年,界面越来越复杂,调试、部署、都要依赖于后端,如果还是以模板的形式开发效率太低了。于是就提出了前后端分离的模式开发,这个时期也是单页应用开始火起来的时期。
到了2014左右,后端开始了微服务模式的开发,这也为微前端提供的思路。随着前端承担的东西越来越多,不断的迭代后,原本简单的单页应用,已经变成了一个巨石应用,不管是代码量还是页面量都非常庞大,一个单页应用由多个团队一起来维护。同时巨石应用还受到了非常大的约束,比如新技术的更新、打包速度等等问题。
因此,在2019年左右,借鉴微服务的思想,提出了微前端的开发模式,也就是将一个大型的单页应用,以业务域为粒度,拆分为多个子应用,最后通过微前端的技术,整合成一个完整的单页应用,同时,每个子应用也能够享有独立应用开发一致的体验。
二、微前端简介
微前端概念
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。Micro Frontends
微前端(Micro Frontends)是一种前端架构模式,借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个前端应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。
微前端的目标是使前端开发更加容易、可维护和可扩展,并且能够实现团队之间的协作。
微前端的特点
- 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
- 独立开发/部署 子应用仓库独立,单独部署,互不依赖
- 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
- 独立运行 子应用之间运行时互不依赖,有独立的状态管理
- 提升效率 微应用可以很好拆分项目,提升协作效率
- 可维护性 微前端可以更容易地进行维护和测试,因为它们具有清晰的界限和独立的代码库
劣势
- 增加了系统复杂度 需要对系统进行拆分,将单体应用拆分成多个独立的微前端应用。这种拆分可能导致系统整体变得更加复杂,因为需要处理跨应用之间的通信和集成问题
- 需要依赖额外的工具和技术 例如模块加载器、应用容器等,这些工具和技术需要额外的学习和维护成本,也可能会导致一些性能问题
- 安全性问题 由于微前端应用是独立的,它们之间可能存在安全隐患。例如,如果某个微前端应用存在漏洞,攻击者可能会利用这个漏洞来攻击整个系统
- 兼容性问题 由于微前端应用是独立的,它们之间可能存在兼容性问题。例如,某个微前端应用可能使用了一些不兼容的依赖库,这可能会导致整个系统出现问题
- 开发团队需要有一定的技术水平 实现微前端需要开发团队有一定的技术水平,包括对模块化、代码复用、应用集成等方面有深入的了解。如果团队缺乏这方面的技能,可能会导致微前端实现出现问题
三、微前端的技术实现
3.1 微前端的基础架构
微前端架构基本需要实现三个部分:
- 主应用接入子应用,包括子应用的注册、路由的处理、应用的加载和路由的切换。
- 主应用加载子应用,这部分之所以重要,是因为接入的方式决定了是否可以更高效的解耦。
- 子应用的容器,这是子应用加载之后面临的问题,包含了JS沙箱、样式隔离和消息机制。
3.2 微前端的主要技术问题
1) 构建时组合 VS 运行时组合
主框架与子应用集成的方式
微前端架构模式下,子应用打包的方式,基本分为两种:
组合方式 | 说明 | 优点 | 缺点 |
---|---|---|---|
构建时 | 子应用与主应用一起打包发布 | 构建的时候可以做打包优化,如依赖共享等 | 主子应用构建方案、工具耦合,必须一起发布,不够灵活 |
运行时 | 子应用自己构建打包,主应用运行时动态加载子应用资源 | 主子应用完全解耦,完全技术栈无关 | 有运行时损耗,多出一些运行时的复杂度 |
要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下需要使用运行时加载子应用这种方案。
2)JS Entry VS HTML Entry
子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。
App Entry | 优点 | 缺点 |
---|---|---|
HTML Entry | 1.子应用开发、发布完全独立 2.子应用具备与独立应用开发时一致的开发体验 | 1.多一次请求,子应用资源解析消耗转移到运行时 2.主子应用不处于同一个构建环境,无法利用bundle的一些构建期的优化能力,如公共依赖抽取等 |
JS Entry | 主子应用使用同一个bundle,可以方便做构建时优化 | 1.子应用的发布需要主应用重新打包 2.主应用需为每个子应用预留一个容器节点,且该节点id需与子应用的容器id保持一致 3.子应用各类资源需要一起打成一个bundle,资源加载效率变低 |
3)样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。
- BEM (Block Element Module)规范命名约束
- CSS Modules 构建时生成各自的作用域
- CSS in JS 使用 JS 语言写 CSS
- Shadow DOM 沙箱隔离
- experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器
- Dynamic Stylesheet 动态样式表
- postcss 增加命名空间
方案 | 说明 | 优点 | 缺点 |
---|---|---|---|
BEM | 不同项目用不同的前缀/命名规则避开冲突 | 简单 | 依赖约定,这也是耦合的一种,容易出现纰漏 |
CSS Modules | 通过编译生成不冲突的选择器名 | 可靠易用,避免人工约束 | 只能在构建期使用,依赖预处理器与打包工具 |
CSS in JS | CSS和JS编码在一起最终生成不冲突的选择器 | 基本彻底避免冲突 | 运行时开销,缺失完整的CSS能力 |
4)JS隔离
一个子应用从加载到运行,再到卸载,有可能会对全局产生一些污染。这些污染包括但不限于:添加 / 删除 / 修改全局变量、绑定全局事件、修改原生方法或对象等。而所有这些子应用造成的影响都可能引起潜在的全局冲突。为此,需要在加载和卸载一个应用的同时,尽可能消除这种影响。目前,主要有两种隔离方式,一种是快照沙箱、另外一种是代理沙箱。
- 快照沙箱的核心思想就是在应用挂载(mount方法)的时候记录快照,在应用卸载(unmount)的时候依据快照恢复环境。
实现的思路是直接用 window diff,把当前的环境和原来的环境做一个比较,跑两次循环(创建快照和恢复快照),然后把两个环境做一次比较,最后去全量的恢复回原来的环境。
- 代理沙箱的核心思想是让子应用里面的环境和外面的环境完全隔离。每个应用对应一个环境,比如应用A对应环境A,应用B对应环境B,同时两者之间的环境和全局环境也互不干扰。
实现思路是主要利用 ES6 的 Proxy 能力。通过劫持window,可以劫持到子应用对全局环境的一些修改,当子应用往window上挂载、删除、修改的时候,把操作记录下来,当恢复全局环境时,反向执行之前的操作。
四、微前端方案
实现方式 | 基本思想 | 优点 | 不足 | 代表方案 |
---|---|---|---|---|
路由分发 | 1.前端框架公共路由方案,映射到不同应用 2.服务器反向代理路由到不同应用 | 1. 维护、开发成本低;2.适应一些关联方不多、业务场景不发展的情况; | 不足:1.独立应用的硬聚合,有比较明显的割裂感 | -- |
前端容器化 | iframe 可以创建一个全新的独立的宿主环境,这意味着前端应用之间可以相互独立运行,仅需要做好应用之间的管理、通信即可 | 1. 比较简单,无需过多改造,开发成本低; 2.完美隔离,JS、CSS 都是独立的运行环境; 3. 不限制使用,页面上可以放多个 iframe 来组合业务 | 1. 无法保持路由状态,刷新后 iframe url 状态丢失(这点也不是完全不能解决,可以将路由作为参数拼接在链接后,刷新时去参数进行页面跳转); 2. 全局上下文完全隔离,应用之间通信困难(比如iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果); 3. iframe 中的弹窗无法突破其本身,无法覆盖全局; 4. 加载慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程 | Iframe |
前端微服务化 | 前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完成的应用 | 1. 应用间通信简单,全局注入; 2. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本; 3. 完备的js、css 沙箱方案,确保微应用之间的样式/全局变量/事件互相不干扰; 4. 具备静态资源预加载能力,加速微应用打开速度 | 1. 适配成本比较高,webpack工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作; 2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重; 3. 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活; 4. 无法支持 vite 等 ESM 脚本运行 | Single-SPA、qiankun |
应用组件化 | Web Components 是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外)并且在 Web 应用中使用它们,其中html imports 被废弃,可以直接加载js即可 | 1. 使用 类WebComponent 加载子应用相比 single-spa 这种注册监听方案更加优雅a; 2. 组件式的 api 更加符合使用习惯,支持子应用保活; 3. 降低子应用改造的成本,提供静态资源预加载能力; 4. 基于CustomElement和样式隔离、js隔离来实现微应用的加载,所以子应用无需改动就可以接入 | 1. 类 webcomponent css 沙箱依然无法绝对的隔离; 2. 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离; 3. 对于不支持 webcompnent 的浏览器没有做降级处理,兼容性不够 | micro-app |
微件化 | 微前端下的微件化是指,每个业务团队编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可 | 1. webpack 联邦编译可以保证所有子应用依赖解耦; 2. 应用间去中心化的调用、共享模块; 3. 模块远程 ts 支持 | 1. 需要相关规范约束各Widget; 2. 没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉; 3. 子应用保活、多应用激活无法实现; 4. 主、子应用的路由可能发生冲突 | EMP |
五、iframe 项目实践
5.1 iframe的概念以及作用
iframe(内联框架)是HTML标签,也是一个内联元素,作用是文档中的文档,或者浮动的框架(FRAME),iframe 元素会创建包含另外一个HTML文档的内联框架(即行内框架) 。
简言之,iframe可以在当前页面嵌入其他页面
5.2 优缺点
优点:
- 内容隔离:可以在同一页面中加载并显示来自不同源的内容,而不会影响主页面的功能
- 异步加载:可以异步加载iframe中的内容,不会阻塞主页面的加载
- 独立滚动:iframe内的内容可以独立滚动,不影响主页面的滚动
- 可以实现复杂的布局和组件,如广告、小工具、第三方插件等
缺点:
- 性能问题:每个iframe都会创建一个新的窗口环境,会消耗更多的内存和CPU资源
- SEO问题:搜索引擎可能无法正确索引iframe中的内容
- 跨域问题:当iframe嵌入网页与主页面不同源,会受到浏览器的安全限制,使用postMessage API需要避免发送敏感信息,或者接收来自不可信源的消息
- 历史记录问题:iframe的导航可能不会更新浏览器的历史记录,可能会影响用户的导航体验
5.3 主要属性
属性 | 值 | 描述 |
---|---|---|
src | URL | 规定在 iframe 中显示的文档的 URL |
class | classname | 规定 iframe 的类名 |
id | id | 规定 iframe 的特定id |
style | style_definition | 规定 iframe 的行内样式 |
title | text | 规定 iframe 的额外信息(可在工具提示中显示) |
width | pixels/percentage | 规定 iframe 的宽度 |
height | pixels/percentage | 规定 iframe 的高度 |
5.4 父子页面通信
5.4.1 单向通信(父传子)
URL传参: 可以在iframe的src属性中使用URL参数,将父页面的数据传递到iframe嵌入的页面。
<iframe src="http://new-iframe-url?param1=value1¶m2=value2"></iframe>
5.4.2 双向通信
父页面和子页面(即iframe内的页面)的通信机制,分为两种
(1)同源的父子页面通信:
如果父页面和子页面同源,可以直接通过JavaScript访问对方的DOM。这是因为同源策略允许同源的文档之间进行完全的交互。
父页面可以通过iframe元素的contentWindow属性获取子页面的window对象,然后直接调用其函数或访问其变量。同样,子页面也可以通过window.parent获取父页面的window对象。
父页面访问子页面:
// 获取iframe元素
const iframe = document.getElementById('myIframe');
// 获取子页面的window对象
const childWindow = iframe.contentWindow;
// 调用子页面的函数
childWindow.myFunction();
// 访问子页面的变量
console.log(childWindow.myVariable);
// 修改子页面的DOM
childWindow.document.getElementById('myElement').innerText = 'hhhh';
子页面访问父页面:
// 获取父页面的window对象
var parentWindow = window.parent;
// 调用父页面的函数
parentWindow.myFunction();
// 访问父页面的变量
console.log(parentWindow.myVariable);
// 修改父页面的DOM
parentWindow.document.getElementById('myElement').innerText = 'hhhh';
(2)不同源的父子页面通信:
如果父页面和子页面不同源,则不能直接通过JavaScript访问对方的DOM。但可以通过HTML5的postMessage API进行跨源通信。
具体来说,一个页面可以通过postMessage方法向另一个页面发送消息,然后另一个页面通过监听message事件来接收这个消息。
父页面发送消息到子页面:
var iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage('Hello', 'https://example.com');
子页面接收来自父页面的消息:
window.addEventListener('message', function(event) {
if (event.origin !== 'https://parent.com') return;
console.log('received message:', event.data);
});
5.5 项目中遇到的问题
问题描述
背景:页面初始化时,子应用iframe要从主应用获取cookie,实现免登
问题实现步骤:清除主应用的cookie,刷新页面,点击加载子应用,此时没获取到cookie,接口报鉴权错误
问题原因
1)异步获取 cookie:cookie 是通过 postMessage 从父页面异步获取的,在发送 HTTP 请求时,cookie可能尚未获取或存储在 sessionStorage 中
2)立即发送请求:在页面组件的 useEffect 钩子中,当组件挂载时立即发送请求,而不等待 cookie 的获取和存储,导致请求发出时缺少 cookie,造成请求失败
cookie交互流程
修复方案
为了确保 token 在 HTTP 请求发送之前已经成功获取并存储,需要实现以下步骤:
1)等待 token 被存储:在 httpMethod 中添加一个辅助函数,用于轮询 sessionStorage,直到 token 被存储
2)在请求之前检查并等待 token:在每个 HTTP 请求方法中,在请求实际发送之前,先调用辅助函数等待 token 被存储
具体实现
修改 api.js文件
1)创建一个waitForToken函数用于等待cookie中的 token
每隔 100ms 检查一次 sessionStorage 中是否存在 Access-Token,并返回一个 Promise。若存在 token ,调用 resolve(token) 方法将 Promise 标记为成功,并返回 token,否则等待 200 毫秒,再次检查token是否存在
const waitForToken = (timeout = 20000) => {
return new Promise((resolve) => {
const startTime = Date.now();
const checkToken = () => {
const token = window.sessionStorage.getItem("Access-Token");
if (token) {
resolve(token); //找到 token,通过 resolve 通知任务成功完成
} else if (Date.now() - startTime >= timeout) {
resolve(null); // 超时后返回 null
} else {
setTimeout(checkToken, 200); //如果没找到,每200ms检查一次
}
};
checkToken();
});
};
2)修改httpMethod
在每个请求方法里调用 waitForToken, 确保在发送请求之前获取到 token,并在获取到 token 后将其从 sessionStorage 中取出并添加到请求头中
const httpMethod = {
get: async (url, params) => {
const token = await waitForToken(); //获取token
return axios
.get(api + url, {
params: params,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
postJson: async (url, data, options = {}) => {
const token = await waitForToken(); //获取token
return axios
.post(api + url, data, {
...options,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
}
六、总结
微前端能否做到利大于弊,具体取决于实际情况和条件。对于小型、高度协作的团队和相对简单的产品来说,微前端的优势相比代价来说就很不明显了;而对于大型、功能众多的产品和许多较独立的团队来说,微前端的好处就会更突出。
工程就是权衡的艺术,而微前端提供了另一个可以做权衡的维度。
学习资料:
来源:juejin.cn/post/7435928578585264138
nextTick用过吗?讲一讲实现思路吧
源码实现思路(面试高分回答) 📖
面试官问我 Vue 的 nextTick
原理是怎么实现的,我这样回答:
在调用 this.$nextTick(cb)
之前:
- 存在一个
callbacks
数组,用于存放所有的cb
回调函数。 - 存在一个
flushCallbacks
函数,用于执行callbacks
数组中的所有回调函数。 - 存在一个
timerFunc
函数,用于将flushCallbacks
函数添加到任务队列中。
当调用 this.nextTick(cb)
时:
nextTick
会将cb
回调函数添加到callbacks
数组中。- 判断在当前事件循环中是否是第一次调用
nextTick
:
- 如果是第一次调用,将执行
timerFunc
函数,添加flushCallbacks
到任务队列。 - 如果不是第一次调用,直接下一步。
- 如果是第一次调用,将执行
- 如果没有传递
cb
回调函数,则返回一个 Promise 实例。
根据上述描述,对应的`流程图`如下:
graph TD
A["this.$nextTick(callback)"] --> B[将回调函数 callback 放入到数组 callbacks 中]
B --> C[判断是否是第一次调用 nextTick]
C -->|是| D[执行 timerFunc, 将 flushCallbacks 添加到任务队列]
C -->|否| F[如果没有 cb, 则retrun Promise]
D --> F
F --> 结束
如果上面的描述没有很理解。没关系,花几分钟跟着我下面来,看完下面的源码逐行讲解,你一定能够清晰地向别人讲出你的思路!
nextTick思路详解 🏃♂➡
1. 核心代码 🌟
下面用十几行代码,就已经可以基本实现nextTick的功能(默认浏览器支持Promise)
// 存储所有的cb回调函数
const callbacks = [];
/*类似于节流的标记位,标记是否处于节流状态。防止重复推送任务*/
let pending = false;
/*遍历执行数组 callbacks 中的所有存储的cb回调函数*/
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/*执行所有cb回调函数*/
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}
function nextTick(cb) {
// 将回调函数cb添加到 callbacks 数组中
callbacks.push(() => {
cb();
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有flushCallbacks被推送到任务队列中去则不需要重复推送
pending = true;
// 使用 Promise 机制,将 flushCallbacks 推送到任务队列
Promise.resolve().then(flushCallbacks);
}
}
测试一下:
let message = '初始消息';
nextTick(() => {
message = '更新后的消息';
console.log('回调:', message); // 输出2: 更新后的消息
});
console.log('测试开始:', message); // 输出1: 初始消息
如果你想要应付面试官,能手写这部分核心原理就已经差不多啦。
如果你想彻底掌握它,请继续跟着我来!!!🕵🏻♂
2. nextTick() 返回promise 🌟
我们在开发中,会使用await this.$nextTick();让其下面的代码全部变成异步代码。
比如写成这样:
await this.$nextTick();
......
......
// 或者
this.$nextTick().then(()=>{
......
})
核心就是nextTick()如果没有参数,则返回一个promise
const callbacks = [];
let pending = false;
function flushCallbacks() {
pending = false;
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]();
}
callbacks.length = 0;
}
function nextTick(cb) {
// 用于存储 Promise 的resolve函数
let _resolve;
callbacks.push(() => {
/* ------------------ 新增start ------------------ */
// 如果有cb回调函数,将cb存储到callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数cb不存在,则保存promise的的成功回调resolve
_resolve();
}
/* ------------------ 新增end ------------------ */
});
if (!pending) {
pending = true;
Promise.resolve().then(flushCallbacks);
}
/* ------------------ 新增start ------------------ */
if (!cb) {
return new Promise((resolve, reject) => {
// 保存resolve到callbacks数组中
_resolve = resolve;
});
}
/* ------------------ 新增end ------------------ */
}
测试一下:
async function testNextTick() {
let message = "初始消息";
nextTick(() => {
message = "更新后的消息";
});
console.log("传入回调:", message); // 输出1: 初始消息
// 不传入回调的情况
await nextTick(); // nextTick 返回 Promise
console.log("未传入回调后:", message); // 输出2: 更新后的消息
}
// 运行测试
testNextTick();
3. 判断浏览器环境 🔧
为了防止浏览器不支持 Promise,Vue 选择了多种 API 来实现兼容 nextTick:
Promise --> MutationObserver --> setImmediate --> setTimeout
- Promise (微任务):
如果当前环境支持 Promise,Vue 会使用Promise.resolve().then(flushCallbacks)
- MutationObserver (微任务):
如果不支持 Promise,支持 MutationObserver。Vue 会创建一个 MutationObserver 实例,通过监听文本节点的变化来触发执行回调函数。 - setImmediate (宏任务):
如果前两者都不支持,支持 setImmediate。则:setImmediate(flushCallbacks)
注意
:setImmediate 在绝大多数浏览器中不被支持,但在 Node.js 中是可用的。 - setTimeout (宏任务):
如果前面所有的都不支持,那你的浏览器一定支持 setTimeout!!!
终极方案:setTimeout(flushCallbacks, 0)
// 存储所有的回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;
/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
// 重置标记,允许下一个 nextTick 调用
pending = false;
/* 执行所有 cb 回调函数 */
for (let i = 0; i < callbacks.length; i++) {
callbacks[i](); // 依次调用存储的回调函数
}
// 清空回调数组,为下一次调用做准备
callbacks.length = 0;
}
// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;
if (typeof Promise !== "undefined") {
// 创建一个已resolve的 Promise 实例
var p = Promise.resolve();
// 定义 timerFunc 为使用 Promise 的方式调度 flushCallbacks
timerFunc = () => {
// 使用 p.then 方法将 flushCallbacks 推送到微任务队列
p.then(flushCallbacks);
};
} else if (
typeof MutationObserver !== "undefined" &&
MutationObserver.toString() === "[object MutationObserverConstructor]"
) {
/* 新建一个 textNode 的 DOM 对象,用 MutationObserver 绑定该 DOM 并指定回调函数。
在 DOM 变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),
即 textNode.data = String(counter) 时便会加入该回调 */
var counter = 1; // 用于切换文本节点的值
var observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
var textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined") {
/* 使用 setImmediate 将回调推入任务队列尾部 */
timerFunc = () => {
setImmediate(flushCallbacks); // 将 flushCallbacks 推送到宏任务队列
};
} else {
/* 使用 setTimeout 将回调推入任务队列尾部 */
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 将 flushCallbacks 推送到宏任务队列
};
}
function nextTick(cb) {
// 用于存储 Promise 的解析函数
let _resolve;
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
// 如果有 cb 回调函数,将 cb 存储到 callbacks
if (cb) {
cb();
} else if (_resolve) {
// 如果参数 cb 不存在,则保存 Promise 的成功回调 resolve
_resolve();
}
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
// 改变标记位的值,如果有 nextTickHandler 被推送到任务队列中去则不需要重复推送
pending = true;
// 调用 timerFunc,将 flushCallbacks 推送到合适的任务队列
timerFunc(flushCallbacks);
}
// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
// 保存 resolve 到 callbacks 数组中
_resolve = resolve;
});
}
}
你真的太牛了,居然几乎全部看完了!
Vue纯源码
上面的代码实现,对于 nextTick 功能已经非常完整了,接下来我将给你展示出 Vue 中实现 nextTick 的完整源码。无非是加了一些判断变量是否存在的判断。看完上面的讲解,我相信聪明的你一定能理解 Vue 实现 nextTick 的源码了吧!💡
// 存储所有的 cb 回调函数
const callbacks = [];
/* 类似于节流的标记位,标记是否处于节流状态。防止重复推送任务 */
let pending = false;
/* 遍历执行数组 callbacks 中的所有存储的 cb 回调函数 */
function flushCallbacks() {
pending = false; // 重置标记,允许下一个 nextTick 调用
const copies = callbacks.slice(0); // 复制当前的 callbacks 数组
callbacks.length = 0; // 清空 callbacks 数组
for (let i = 0; i < copies.length; i++) {
copies[i](); // 执行每一个存储的回调函数
}
}
// 判断是否为原生实现的函数
function isNative(Ctor) {
// 如Promise.toString() 为 'function Promise() { [native code] }'
return typeof Ctor === "function" && /native code/.test(Ctor.toString());
}
// 判断最终支持的 API:Promise / MutationObserver / setImmediate / setTimeout
let timerFunc;
if (typeof Promise !== "undefined" && isNative(Promise)) {
const p = Promise.resolve(); // 创建一个已解决的 Promise 实例
timerFunc = () => {
p.then(flushCallbacks); // 使用 p.then 将 flushCallbacks 推送到微任务队列
// 在某些有问题的 UIWebView 中,Promise.then 并不会完全失效,
// 但可能会陷入一种奇怪的状态:回调函数被添加到微任务队列中,
// 但队列并没有被执行,直到浏览器需要处理其他工作,比如定时器。
// 因此,我们可以通过添加一个空的定时器来“强制”执行微任务队列。
if (isIOS) setTimeout(() => {}); // 解决iOS 的bug,推迟 空函数 的执行(如果不理解,建议忽略)
};
} else if (
typeof MutationObserver !== "undefined" &&
(isNative(MutationObserver) ||
MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
let counter = 1; // 用于切换文本节点的值
const observer = new MutationObserver(flushCallbacks); // 创建 MutationObserver 实例
const textNode = document.createTextNode(String(counter)); // 创建文本节点
observer.observe(textNode, {
characterData: true, // 监听文本节点的变化
});
// 定义 timerFunc 为使用 MutationObserver 的方式调度 flushCallbacks
timerFunc = () => {
counter = (counter + 1) % 2; // 切换 counter 的值(0 或 1)
textNode.data = String(counter); // 更新文本节点以触发观察者
};
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
timerFunc = () => {
setImmediate(flushCallbacks); // 使用 setImmediate 推送到任务队列
};
} else {
timerFunc = () => {
setTimeout(flushCallbacks, 0); // 使用 setTimeout 推送到宏任务队列
};
}
function nextTick(cb, ctx) {
let _resolve; // 用于存储 Promise 的解析函数
// 将回调函数 cb 添加到 callbacks 数组中
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx); // 执行传入的回调函数
} catch (e) {
handleError(e, ctx, "nextTick"); // 错误处理
}
} else if (_resolve) {
_resolve(ctx); // 解析 Promise
}
});
// 第一次使用 nextTick 时,pending 为 false,下面的代码才会执行
if (!pending) {
pending = true; // 改变标记位的值
timerFunc(); // 调用 timerFunc,调度 flushCallbacks
}
// 如果没有 cb 且环境支持 Promise,则返回一个 Promise
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve; // 存储解析函数
});
}
}
总结
通过这样分成三步、循序渐进的方式,我们深入探讨了 nextTick 的原理和实现机制。希望这篇文章能够对你有所帮助,让你在前端开发的道路上更加得心应手!🚀
来源:juejin.cn/post/7433439452662333466
2023总结:30岁,结束8年北漂回老家,降薪2/3,我把人生过的稀烂
一转眼又快过年了,回想整个23年,简直是我人生中最黑暗的一年(之一)。
23年,我30岁,在北京干了8年程序员。30岁这年我做了一个决定:结束8年北漂生涯,回老家(一个三线城市)自己创业,去做自媒体。
一、为何做出这个决定
这个决定也不是一时拍脑袋做出的决定,导火索是在22年:
那时候大环境不好,大家都越来越卷,下班的时间也越来越晚。
放假回家亲戚朋友总说,你在北京996这么累,图啥啊,工资是高点,但是完全没有生活啊。而且你在北京漂到啥时候是个头?你又买不起房,又没户口,早晚得回来吧。
我仔细想想也有道理,活了这么多年了都在当牛做马,被pua,还得面临35岁危机,真的受够这种生活了!所以那时候心里埋下了一颗种子:我要去浪浪山的那边看看!
其实我本身就是一个喜欢自由的人,这么多年那句“打工是不可能打工的,这辈子都不会打工”一直激励着我,我想自己有一天也能实现不打工这个目标。
于是22年底我做了一个决定:23年去山的那边看看大海的样子!拿完年终奖就辞职!去创业,去开启我的新的人生!
在准备辞职前的几件事情,都让我更加坚定了辞职的决心:
- 那时候还没有放开,在家线上办公,本来在公司办公是995,晚上9-10点下班了基本就没啥事情了,但是在家就不一样了,每天各种电话、视频会议,甚至十一二点都要开会,恨不得让你24h都在线,生活和工作基本都没有边界。那个时候只要听到会议呼叫的声音,内心就一紧,心中默默祈祷不要出什么幺蛾子,都快成心理阴影了。
- 当时得了新冠也只敢请了一天假,第二天晕晕乎乎的就继续开始工作了。因为我知道,落下的工作最后还得你自己加班完成,否则领导最后还会赖你延期。
- 周末也需要随时在线,需要及时回复群里的消息,需要随时解决线上的问题,否则就会打上工作态度不好的标签,绩效肯定低。导致我周末基本不敢出去,出去也得随时看着手机,看有没有@你的消息,整天提心吊胆,玩也玩不好,还不如在家躺着。
我觉得这不是我想要的生活,每天太累了,身体累不算,心还累,生怕自己负责的业务出了什么问题,如坐针毡,如芒刺背,如履薄冰。
二、我辞职了
终于,熬到23年,拿到年终奖后,我决定提出离职。
当时身边有些人劝我不要辞职,说现在环境不好,更不应该放弃你的老本行,去做啥自媒体。
我当时内心嗤之以鼻,心想程序员这行也就干到35岁,而且现在卷的不行,加班加的身体都快废了,这行岁数大了没前途!我趁现在30岁还年轻,创业正值当年,辞职改行的选择非常有战略眼光!(当时真的是感觉杰克马附体,准备在这个三十而立的年纪,大干一场!)
当然我也不是脑袋一热就想着辞职去做自媒体,辞职前我做了充足的准备,和很长时间的调研&分析:
- 我作为一个互联网人,做实体店肯定不是我擅长的,肯定只能从互联网上选择行业,互联网项目有很多:个人工具站,知乎好物,闲鱼二手书,小红书带货,抖音带货,抖音个人ip,公众号写作,短剧cps,小说推文,知识付费等等的项目,我可以说都看了一个遍,其中抖音现在流量最大,要做风口上的猪,做抖音相关肯定要容易很多。
- 然后我也学习了一些创业相关的知识,比如如何找对标,如何分析对方商业模式,参加了很多知识付费的圈子,然后还报了小红书和抖音的培训班,总共加起来得有1w多呢。
- 而且我还预留了充足的资金,我做了最坏的打算,就算我一分钱不挣,也够我活3年呢,我不会3年都创业不成功吧!(此处白岩松表情包:不会吧!.jpg)
三、想象很美好
为了这次创业,我还制定了计划,年度计划,季度计划,月计划,周计划,天计划,真的非常详细。
我也要很自律,每天按时起床,锻炼,学习,做业务。这次我真的抱着必胜的决心来做!
当然我也提前列出可能要遇到的风险,并思考解决方案:
比如项目进展慢怎么办,拖延症怎么办,家人反对怎么办,朋友约吃饭打乱我的计划怎么办,遇到困难我该怎么应对等等
这么一套组合拳下来,我觉得已经万事俱备,只差开干了!
四、现实很残酷
4月我如期辞职,当时正值清明节,淅淅沥沥的小雨并没有浇灭我开启新生活的热情。辞职后,我就按计划开始早睡早起,锻炼,学习,搞创业的事情。
但是马上就被打脸了,这是我创业中遇到的第一个难题,也是我万万没有预料到的
就在我创业后的不久,我患上焦虑症,失眠了,而且还很严重,就是那种从晚上11点躺下,躺到早上6点才睡着的那种失眠,而且还时不时的心悸。
我万万没想到会患上失眠症。因为我觉得没有上班的压力了,想啥时候干活就啥时候干活,想干多少干多少,想啥时候下班就啥时候下班,也没人pua我了,还有时间锻炼,应该睡得更好才是。
但实际并不是这样,对于一个从小被学校管,长大了被公司管的芸芸众生来说,创业实际是这样的:
- 你会非常忙,比上班还要忙,因为你之前是螺丝钉,做好自己的本职工作就好了,现在事无巨细,都你一个人。比如做自媒体,从开始的账号定位-》内容选题-》写脚本-》置景&拍摄-》后期剪辑-》选品-》商务对接-》客服-》用户社群运营,所有的环节,都得你自己一个人。然后视频没流量怎么办,违规了怎么办,付费转化率低怎么办,还是只有你自己去解决。(之前公司让你干啥你干啥,你只需要规定时间完成你的任务就好了)
- 面对大量的自由时间,你根本不会支配时间,因为很多环节你都是小白,要学习的东西太多,但是你天天光学习了,每天看似很忙,但是看不到产出,导致你就很沮丧。(之前你只做熟悉的工作,产出是有保证的)
- 行动困扰,没有目标感,没有人给你一个目标&方向,你不知道你现在做的事情对挣钱有没有价值,你会迷茫,你会时常自我怀疑。(之前你只是专注领导安排的任务,至于这个任务能不能帮公司挣到钱,那是公司的事情,你关心到手的工资而已)
- 没有成就感,认同感。因为现在你很多事情要从0开始,比如写文案要求写作能力,拍视频要求表现力,搞流量要求你有运营&营销的能力 ,相比之前做熟悉工作,感觉上会有落差(之前工作中都是做你擅长的领域,每完成一项任务也很有成就感,做的出色还能收获同事和领导的认可)
- 和社会断了链接,没有存在感,归属感(这是人类的基本需求之一),你不属于任何一个群体,没有人赞扬,尊重,接纳你,甚至你想被骂两句也没人鸟你(之前在公司,做的好了领导或者同事会夸你两句,做的不好了可能会给你建议,起码有人能倾诉,能交流,能寻求帮助)
- 没有了收入,眼见钱包一天天变少,你肯定会焦虑。但是更让你焦虑的,是你不知道未来什么时候能挣到钱。更更让你焦虑的,是不知道最后能不能挣到钱。(之前工作压力不管有多大,多累,起码你还有工资,你还有吃饭的钱,这是底气)
所以在此奉劝有裸辞创业想法的人,千万不要裸辞!裸辞创业九死一生! 正确的做法是一边工作愿一边做副业,等副业的收入和工资差不多甚至超过工资了,再去辞职。有人会说,我工作那么忙,根本没时间搞副业啊。我之前也是这么想的,但是现在我会告诉你:没有时间就去挤出时间,每天晚睡或者早起一会,周末也抽出时间搞。这点问题都解决不了?创业的遇到问题会比这难十倍!如果这个你觉得太难了,那我劝你还是老老实实打工吧。
但是我已经裸辞了,没办法,只能去解决问题,我开始吃助眠药,喝中药,有些好转,但也没治好,只是比之前好点。
就这么拖着残血的半条命,我坚持了半年多,一半时间学习,一半时间实践,搞了两个自媒体号,第一个号违规被封了,第二个号流量也没啥起色。这条路是越走越看不到希望,每天晚上都不想睡觉,因为害怕明天的到来,因为明早一起床,眼前又是一片黑暗。
五、彻底崩溃
11月,因为种种原因和媳妇生了一场气,我觉得对于我创业,她不鼓励也就算了,在我状态这么差的情况下还不能对我包容一点,甚至有点拆后台的感觉,那几天我就像一个泄了气的皮球,内心被彻底击垮了。(所以现在有点理解每个成功男人的背后,都有一个伟大的女人
这句话的含义了)
终于,在创业的压力,8个月没有收入的恐慌,焦虑失眠心悸的折磨中,我决定放弃了。
失败了,彻彻底底的失败。回想这次经历,就好像之前在一艘航行的货轮上打工,然后受不船上的种种压榨,终于鼓起勇气,自己带着一艘救生艇,跳海奔向自己想要的自由。结果高估了自己的目前的实力,经不起茫茫大海狂风骤雨,翻船了。。濒临溺亡。。。
六、重新找工作
放弃后的那几周,我开始熬夜,开始暴饮暴食,之前的运动也放弃了。整天在家里拉着窗帘,除了吃饭就是躺在床上刷手机,让我尽可能分散注意力,减少内心的痛苦。
但是这样的状态也不是事儿啊,目前肯定是不想再去面对创业的事情了,那只能去找个工作先干着了。
刚开始找工作内心又有不甘,因为一个三线城市比起北京来说,不管是工作机会,环境,薪资来说,都差太多。
但是没办法,我现在的感觉就是快溺死了,我现在急需一个救命稻草,活下来,是我目前的首要任务。
于是在网上海投了一遍,结果惨不忍睹,根本没几家公司招人,前前后后一个月,真正靠谱的面试就一家,是的,只有一家。
好在这家也顺利拿了offer,是一家刚创业的公司,一共十几个人,薪资只有原来1/3多点,但是拿到offer那一刻我依然有些激动,我感觉我活下来了,不管怎样,现在能喘口气了。
七、迷茫的未来
现在上班已经一个多月了,公司挺好,不加班,基本上7点前就都走了,离家也挺近,骑个共享单车也就10分钟。这一个月,焦虑没了,不心悸了,失眠也好了。每天就是按部就班上下班,完成老板给的任务,其他的事情也不用自己操心,终于又做起自己熟悉且擅长的事情。
但是内心还是有落差,本来北京好好的工作自己作死给辞了,要不这一年也能攒不少钱呢,现在不但钱没了,这几个月还花了好几w,最后还差点嘎了。
其实入职这家公司前,北京之前的同事问我要不要回去,说现在正忙,我说你先问问吧。
我当时也纠结,如果真的能回去,我还要不要回去,毕竟在那边挣一个月顶这边仨月。但是回都回来了,再去北京可能就一辈子留北京了吧。
不过后来同事说年前没有招人计划了,可能要年后了,如果招人到时再联系我。正好我不用纠结了,这可能就是命运的安排吧。
不过真的想问问你们,如果到时有机会,是继续北漂呢,还是选择在老家呢?
八、结语
说实话,我现在知道了,山的那边还是山,我不知道什么时候才能看到海,甚至我可能一辈子都看不到海了。不过目前想的就是,调整好状态,先走一步算一步吧。
30岁的年纪,学会和自己和解,学会接受自己的平庸,但是依然要努力,毕竟在这个阴雨连天的环境下,没有伞的孩子只能努力奔跑。
来源:juejin.cn/post/7330439494666453018
用Vant组件库来做移动端UI界面能有多方便?🚀🚀🚀
前言
最近写一个移动端的项目,用到了vant做UI界面设计,不得不再次感叹开源的力量,这个组件库封装的实在是太优雅了,只要你愿意看官方文档,就不会担心看不懂,也不会担心不会用,接下来就带大家去浅尝一下这个组件库。官网文档:Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)
Vant
Vant 是一个轻量、可定制的移动端组件库,于 2017 年开源。
可以看到,移动端
,用来做移动端
的ui界面。
目前 Vant 官方提供了 Vue 2 版本、Vue 3 版本和微信小程序版本,并由社区团队维护 React 版本和支付宝小程序版本。
特性
- 🚀 性能极佳,组件平均体积小于 1KB(min+gzip)
- 🚀 80+ 个高质量组件,覆盖移动端主流场景
- 🚀 零外部依赖,不依赖三方 npm 包
- 💪 使用 TypeScript 编写,提供完整的类型定义
- 💪 单元测试覆盖率超过 90%,提供稳定性保障
- 📖 提供丰富的中英文文档和组件示例
- 📖 提供 Sketch 和 Axure 设计资源
- 🍭 支持 Vue 2、Vue 3 和微信小程序
- 🍭 支持 Nuxt 2、Nuxt 3,提供 Nuxt 的 Vant Module
- 🍭 支持主题定制,内置 700+ 个主题变量
- 🍭 支持按需引入和 Tree Shaking
- 🍭 支持无障碍访问(持续改进中)
- 🍭 支持深色模式
- 🍭 支持服务器端渲染
- 🌍 支持国际化,内置 30+ 种语言包
没错,这是官网复制来的介绍。
那么从官方文档可以看到目前这是Vant4.x,适用于vue3,如果你用的是vue2,那么就用Vant2。我们切换到快速上手,就能看到如何快速入门使用
Vant使用
1.安装
在现有项目中使用 Vant 时,可以通过 npm
进行安装:
# Vue 3 项目,安装最新版 Vant
npm i vant
# Vue 2 项目,安装 Vant 2
npm i vant@latest-v2
看得出来,非常优雅,最新版直接安装vant,如果不是最新的才加了些-2...
当然,你也可以通过 yarn
、pnpm
或 bun
进行安装:什么方式都有,大家可以自己参照官方文档。
# 通过 yarn 安装
yarn add vant
# 通过 pnpm 安装
pnpm add vant
# 通过 Bun 安装
bun add vant
那么引入之后如何使用呢?我们常常需要做一个登陆注册页面,看看有了vant组件库之后,我们还需要去切页面吗?
此处我们需要放一个表单,接下来我们查阅官方文档,看看vant的表单如何使用。
2.引入
写的很清楚,表单会用到三个组件,我们只需要从vant库中引入这三个组件,然后都use掉即可
import { createApp } from 'vue';
import { Form, Field, CellGr0up } from 'vant';
const app = createApp();
app.use(Form);
app.use(Field);
app.use(CellGr0up);
引入之后,就可以在我们需要用到表单的页面使用了。直接将他写好的表单复制到我们的代码中
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="username"
name="用户名"
label="用户名"
placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]"
/>
<van-field
v-model="password"
type="password"
name="密码"
label="密码"
placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
可以看到,它使用了v-model双向绑定,因此我们就需要自己去定义一个username、passwor...看需要用到什么,然后官方文档就直接了当告诉我们了这些变量
import { ref } from 'vue';
export default {
setup() {
const username = ref('');
const password = ref('');
const onSubmit = (values) => {
console.log('submit', values);
};
return {
username,
password,
onSubmit,
};
},
};
- 注意他这里提交表单事件,直接接收了一个参数values,事实上这个values可以直接通过values.name,我们input框上的name属性代表的input框拿到的数据,在这里其实就是values.用户名,所以我们可以直接改成英文,如:values.username。
在官方文档上也能看见他的样式,甚至可以做校验
除此之外,再介绍一个,例如微信中点击某个东西成功之后,就会弹出一个弹出框,打勾,或者失败,如果这么一个UI界面要我们自己去写可能多少也有点麻烦,但是vant都给我们封装好了
引入
通过以下方式来全局注册组件,更多注册方式请参考组件注册。
import { createApp } from 'vue';
import { Toast } from 'vant';
const app = createApp();
app.use(Toast);
显然就是引入一个封装好了的Toast组件,然后use掉,vant官方都给我们想好了,一般这种弹出提示框都是我们js中做判断,如果成功就弹出,因此一般都出现在函数里面,官方就直接给我们打造了函数调用的方式
import { showToast } from 'vant';
showToast('提示内容');
全局引入依赖之后,在js中直接引入,然后直接调用就能出现效果,帮助咱们切图仔省去了不少麻烦事儿。
除了成功、失败,他甚至可以自定义写图标,也是极度舒适了。
看看我的登录页面是如何使用的:
<template>
<div class="login">
<h1>登录</h1>
<div class="login-wrapper">
<div class="avatar">
<img src="https://q6.itc.cn/q_70/images03/20240601/80b789341c9b45cb8a76238650d288a5.png" alt="">
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field v-model="username" name="username" label="用户名" placeholder="用户名"
:rules="[{ required: true, message: '请填写用户名' }]" />
<van-field v-model="password" type="password" name="password" label="密码" placeholder="密码"
:rules="[{ required: true, message: '请填写密码' }]" />
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from '@/api/index.js'
const password = ref('')
const username = ref('')
// 登录
const onSubmit = (values) => {
// console.log(values); // 用户输入的账号和密码对象 name作为key
// 发送接口请求给后端 校验数据
axios.post('/user/login', {
username: values.username,
password: values.password
}).then((res) => {
console.log(res);
})
}
</script>
<style lang="less" scoped>
.login {
width: 100vw;
height: 100vh;
background-color: #fff;
padding: 0 0.3rem;
box-sizing: border-box;
overflow: hidden;
h1 {
height: 0.6933rem;
text-align: center;
font-size: 0.48rem;
margin-top: 1.12rem;
}
.login-wrapper {
width: 7.44rem;
height: 10.77rem;
border: 1px solid rgba(187, 187, 187, 1);
margin: 0 auto;
margin-top: 1.7rem;
border-radius: 0.3rem;
box-shadow: 0 0 0.533rem rgba(170, 170, 170, 1);
.avatar {
width: 2.4rem;
height: 2.4rem;
margin: 1rem auto 0.77rem auto;
border-radius: 50%;
overflow: hidden;
img {
width: 100%;
}
}
}
}
:deep(.van-cell__title.van-field__label) {
width: 45px;
}
</style>
非常方便,跟着上述步骤,官方文档写的很清楚,直接贴上去就实现了,然后自己再加一些less样式。
那么其他的就不做过多介绍了,大家在做项目需要用到一些东西的时候,直接查阅官方文档,直接拿去用即可。Vant 4 - 轻量、可定制的移动端组件库 (vant-ui.github.io)
在这里我写的都是rem单位,目的是做移动端的适配,根据宽度调整fontsize的大小,其实也可以通过vant封装好的移动端适配组件来做,这里不做过多介绍了。
小结
Vant 是一个轻量、可靠的移动端组件库,可用于快速构建风格统一的移动端 UI 界面。它提供了一系列高质量的组件,涵盖了导航、表单、按钮、列表、轮播图等常用功能。
我们只需要安装依赖后引入依赖还有在全局中引入他的整份css代码,最后按需取组件,就能轻松完成ui界面设计。
来源:juejin.cn/post/7389577588174503936
即梦AI推出“一句话改图”功能,助力用户发掘更多创意
近日,字节跳动旗下AI内容平台即梦AI上线了指令编辑功能,用户使用即梦AI的“图片生成”功能时,在上传导入参考图片后,选择“智能参考”,在文本框中输入想要如何调整图片的描述,就可以轻松对图片进行编辑。目前,该功能在即梦AI网页版和App移动端均可免费体验。
图说:即梦AI网页版指令编辑功能使用示意
据介绍,即梦AI的指令编辑功能支持包括修图、换装、美化、转化风格、在指定区域添加删除元素等各类编辑操作,通过简单的自然语言即可编辑图像,大幅降低了用户操作成本,有助于用户发掘和实现更多创意。
图说:即梦AI指令编辑功能创意玩法
据介绍,即梦AI的指令编辑功能由字节最新通用图像编辑模型SeedEdit支持。SeedEdit是国内首个实现产品化的通用图像编辑模型。过往,学术界在文生图和图生图领域已有较多研究,但做好生成图片的指令编辑一直是难题,二次修改很难保证稳定性和生成质量。今年以来,Dalle3、Midjourney接连推出产品化的生图编辑功能,相较业界此前方案,编辑生成图片的质量大大改善,但仍缺乏对用户编辑指令的精准响应和原图信息保持能力。SeedEdit在通用性、可控性、高质量等方面取得了一定突破,适用各类编辑任务,支持用户脑洞大开的奇思妙想,无需再训练微调即可快捷应用。
即梦AI支持通过自然语言及图片输入,生成高质量的图像及视频。平台提供智能画布、故事创作模式,以及首尾帧、对口型、运镜控制、速度控制等AI编辑能力,并有海量影像灵感及兴趣社区,一站式提供用户创意灵感、流畅工作流、社区交互等资源,为用户的创作提效。近期,即梦AI还面向用户开放了字节视频生成模型Seaweed的使用。
该平台相关负责人表示,AI能够和创作者深度互动,共同创作,带来很多惊喜和启发,即梦AI希望成为用户最亲密和有智慧的创作伙伴。(作者:李双)
收起阅读 »开发人员,千万不要去碰那该死的业务参数,无论什么时候!
你好呀,我是歪歪。
前几天发了一个牢骚:
本来只是单纯的吐槽一下,但是好多人对其中的细节比较感兴趣。
大家都是搞技术的嘛,对于“踩 BUG”这种喜闻乐见的事情,有兴趣是很正常的。
其实我这个 BUG,其实严格意义上不能叫做 BUG,因为和程序无关,甚至和技术的关系都不算大。从标题上你也能猜出来,是和一个业务参数相关。
但是在这个过程中,因为我是整个事件全程的亲历者,所以现在回看这个事情,我还是有一些思考在里面的。
我觉得这是一个程序员会遇到的“典型事件”。
那就用这篇文章一起复盘一下吧。
背景
要说明这个问题的背景,甚至不需要一个具体的业务场景,只需要围绕着以下这个非常常见的利息计算公式,就可以说明问题的起因:
利息=计息金额*日利率。
日利率=年利率/360
由于日利率的计算,涉及到除法,在对应需求第一次开发时,业务的要求是日利率保存 7 位小数。
在程序中,年利率和日利率是两个字段分别保存的,日利率在初始化的时候就算好落库了,后续程序直接取这个算好的日利率就行了。
系统上线,相安无事。
跑了一段时间后,业务又提来一个需求:当前的精度不够,需要调整到 11 为小数。
你不用好奇歪师傅这边到底是什么业务场景,反正我去看了业务数据,需求是合理的,那就把需求接过来干就行了。
保存 7 位小数和 11 位小数,大家都是搞开发的,肯定也知道这个就是一个小改动,很快就能搞定。
事实也是如此,虽然之前的需求对应的代码不是我写的,但是我看过代码,清楚的知道改动点在哪,所以很快就开发完成。
前面说了,这个需求之前在线上按照 7 位小数跑了一段时间,所以存在一些存量配置。
针对这些存量数据,在需求评审会议上的时候,我提了一句:存量配置怎么处理呢?
业务答复:这次需求上线的时候,你按照 11 位小数重新算好,然后写 SQL 更新一下就行。
我心里一盘算:计算公式明确,年利率我也有,算一把,没啥问题。
就答应了。
然后,不出意外的出意外了。
假设年利率是 2.5%,除以 360 之后,保留 11 位小数,应该是 0.00006944444。
而我不知道当时为什么手抖了,在 SQL 里面写成了 0.00069444444。
我给你对比一下:
0.00006944444
0.00069444444
相当于我写出来的日利率被扩大了十倍。
然后再回头看看这个公式:
利息=计息金额*日利率
日利率被扩大十倍,那么对应的计提金额也会被扩大十倍。
这就是问题的背景。
一个单纯的人为失误,和程序没有任何关系,所以严格意义上不属于程序 BUG。
但是这个问题确实是足够低级。
为什么没被发现?
那么这个错误的 SQL 是怎么通过代码评审、测试验证这两道关卡被带到生产环节的呢?
首先,这一次提交的代码,压根就没有评审环节。
我有代码提交权限,也有代码审核权限。所以我自己提交,自己就审核通过了。
其实这个需求应该是组里面另外一个小伙伴来做,但是当时他被调到其他组了。
他还在我们组的时候,我们的合作模式是他提交代码,我进行审核。
如果有这个环节,我觉得我有 50% 的几率发现问题。
为什么是 50% 呢?
因为这取决于我审核代码时是否有正在处理其他的事情,如果有其他事情处理,我可能会形式主义的看上几眼。如果没有其他事情,而这次提交的代码量又不大的话,我基本上都会认真的过一下提交的内容。
通过代码评审之后,接下来就应该是测试环节。
测试主要关注的是精度从 7 位变成 11 位之后,最终计算出来的利息是否符合预期。
他测试时是走了整个业务的全流程。
在“全流程”中,这个 11 位精度的日利率,是在页面配置年利率的时候通过程序自动计算出来的,不会错的。
而他在验证 SQL 语句的时候,测试环境又没有生产环境的配置,所以他拿着我提供的 SQL,只能保证写的语法没问题,能正常执行,并不能确保里面数据的正确性。
而我也记得很清楚,我当时给他说过:你执行一下 SQL 不报错就行,值的正确性,我来保证。
而且戏剧性的是,测试同事很仔细的去看了值,他去数了确实是 11 位小数。但是可惜,站在他的视角,他发现不了值被扩大了十倍。
所以,测试环节也没有发现这个问题:
0.00006944444
0.00069444444
就带着上生产了。
一个问题正常来说不应该被带上生产,但是我们确实不能保证测试环节一定能把所有问题都测出来,所以新项目、新迭代的生产验证也是非常有必要的。
这个我们也做了。
按理来说,生产上的数据已经是错误的了,而且是一个“利息金额扩大十倍”的明显的错误,如果主动去做了数据验证,应该能被发现才对。
那为什么做了生产验证,却没有发现问题呢?
因为当时存量配置有三条,我提供了 3 个 SQL,其中有一个是算对了的。
每一条存量配置都对应着大量的利息数据,而算对了的这个对应的数据更多,在比例上超过 60%。
我进行生产验证的时候,在大量的利息计提数据中随机抽选了两条,选中的这两条,恰好都是正确的 SQL 对应的数据。
所以我发现符合预期,得出了生产验证通过的结论。
站在这个节点,回顾整个事件,这个时候应该是最有可能发现问题的时候。
但是没发现。
根本原因是验证方案不严谨,玄学原因是运气不站在我这边。
怎么暴露的?
你想想,这种业务参数配置错误的问题你能通过什么监控规则监控到吗?
其实很难的。
我们一般来说做技术层面的监控,都是监控程序是否按照预期正常运行。比如在计算的过程中出现异常,那我们是可以监控到的。
但是在这种只是参与计算的值不对,但是能正常计算出一个值的情况,并不会报错。
这种问题通过技术手段很难监控到。如果硬要去做监控,肯定是能做的,比如从异常浮动的维度、横向数据对比的维度,但是配套的开发成本又上去了。
我是怎么发现这个问题的呢?
也是纯粹的运气。
是一个周五的晚上,我做另外的一个和本问题毫无关系的场景下的数据验证的时候,偶然间看到了一笔数据的金额和前几天比,明显大了很多。
这是不符合业务规律的。
然后进一步跟踪,最终定位到了前面的问题 SQL。这个时候距离这个 SQL 上线,已经过去了三天,已经产生了一批错误数据了。
如果我没有偶然间看到这个问题数据,那么这个问题会在什么环节暴露呢?
就是在业务使用这个数据做核对的时候。
那个时候整个问题的性质就变了。不仅是处理时间来不来得及的问题了,而是这个问题是由“开发自主发现”还是由“外部反馈发现”这两个完全不同的性质了。
一般来说,不管是什么问题,先抛开严重程度,只要是开发自主发现的,都能一定程度上让事情变得不那么难堪。
所以我们才一度强调“可监控”的重要性。
随后,我联系了业务,反馈了这个情况。他表示在他下次使用这批数据之前,把数据修复好就行。大概一个月后,他会用到这批数据。
这样,我有接近一个月的时间来处理这个问题,防止问题扩大化。
时间非常充足,站在这个角度,我运气还不错的。
问题已经暴露出来了,随后就是制定针对这批错误数据的修复方案了。
修复方案就和业务场景相关了,属于多个业务场景叠加在一起,所以修复方案其实是比较复杂的,涉及到“修数”和“补数”,没有展开描述的必要了。
只是想简单提一句,这个修复方案是我利用周末的时间想出来的,很多细节问题我都需要考虑到,甚至在心里写了一遍伪代码。
确实是浪费了周末的时间,但是这是为自己的错误买单,半点不怨别人,就是活该。
而对于参与后续方案讨论的同事来说,在这件事情上付出的时间,才是属于无妄之灾。
这就是整个事情的过程,一个小数点引发的血案。
再回首
现在整个事情的全貌都在你眼前了,你得到了什么经验教训?
因为手抖了,写错了一位小数,这确实是直接原因,所以是想着下次再处理这种数据的时候,更加小心一点吗?
我觉得不是这样的。
我得到的经验教训就是我的标题:开发人员,千万不要去碰那该死的业务参数!
如果在最开始需求评审会,我们讨论到存量数据的时候。
业务说:这次需求上线的时候,你按照 11 位小数重新算好,然后写 SQL 更新一下就行。
我说:不行,这个属于是业务参数,我不能去动。上线完成后,就具备这个功能了,你可以通过页面配置去修改。
我知道他们修改业务参数的流程,很长很复杂。
首先业务需要发起一个参数变更的 OA 流程,然后走到他的部门负责人审批。
业务部门负责人审批完成后,会到具体负责业务参数配置的人员手里,还需要该人员对应的部门负责人审核。
审核完成后有权限的人员才会去修改这个业务参数,而这个参数的修改,在对应的系统功能上还有两级甚至三级审核。
整个完成之后发起 OA 的人员还需要进行变更确认,看看页面上是否是自己想要的配置。
这一套流程走下来,你觉得还会出错吗?
很难出错了。
你可以批判这个流程过于臃肿,但是你最终总是会认识到,这个流程其实是在保护打工人。
我知道他流程比较复杂,而我写个 SQL 几乎是没有成本的,但是这是在 SQL 正确的前提下。
如果当时不答应通过 SQL 的方式帮他处理存量数据,他其实有更加正规的流程去处理这些数据,而且不会出错。
事后我们复盘的时候,也有同事私下向我提出了这个的问题:为什么不走 OA 流程去调整这个参数?
另外,关于流程,我给你举一个程序员方面的例子。
一个核心开发人员拥有线上数据库的操作权限,我们先假设这个人绝对忠诚、绝对可以信赖、绝对恪尽职守、绝对不会删库跑路。
某一天,他收到一个预警信息,经过排查发现需要去修改数据库里面某个数据的状态,他直接就去修改了。
这个操作非常常见,特别是在小公司或者在一些在快速发展阶段的公司。
后来这个公司成长起来了,开始更加注重操作风险了,回收了所有人员的数据库权限,以前的事儿既往不咎,以后想要修改数据库数据,必须要发起一个审批流程,经过层层审批之后才能执行。
这个流程和“直接去修改”这个动作比起来,就重了无数倍了。
站在程序员的角度,前几年都是可以直接操作生产数据,突然这个制度出来了,极大的影响了之前的开发惯性。所以刚刚开始执行的时候,你可能会骂一句:xxx。
但是长远来看,这个流程其实是在保护你。
当你有数据库权限的时候,操作对了,没有人会夸你。操作错了,你就是罪魁祸首。
有了一个审批流程,在加重了操作成本的同时,也降低了错误成本。
处理问题的时长可能增加了,对于问题处理的敏捷度可能降低了,但是站在公司的角度,随着公司的发展“稳定”才是永恒的主旋律,在稳定面前,敏捷度反而是可以牺牲的。
歪师傅在第一家公司业务野蛮发展的时代,曾经就有这样的权限,那个时候刚刚参加工作两年多的时间,觉得事情就应该是这样的,这样才是正确的,可以足够敏捷,足够迅速的处理问题。
后来权限回收了,当时我也在私底下骂骂咧咧了几句。
再回来,随着经验和在职场上见过得事儿越来越多,才渐渐认识到:蛮荒时代确实出英雄,但是我没有把握好机会成为英雄。蛮荒时代之后的流程规范,规章制度其实是在保护那批没有成为英雄的人,其中就有我。
最后,给你,也给我自己一个忠告:开发人员,你最好要知道你数据库里面每一个业务参数背后的业务含义,但是千万不要去碰那该死的业务参数。也轮不到你碰,该碰的人会在正确的流程下去碰。
无论什么时候,心中都要绷着这根弦。
来源:juejin.cn/post/7435928632081252378
彻底理解import和require区别和用法
前言
在真实工作中,估计import
和require
大家经常见到,如果做前端业务代码,那么import
更是随处可见了。但我们都是直接去使用,但是这两种方式的区别是什么呢?应用场景有什么区别呢?
大部分能说出来import
是ES6
规范,而require
是CommonJS
规范,然后面试官深入问你两者编译规则有啥不一样?然后就不知道了
本文一次性对import
和require
的模块基本概念
、编译规则
、基本用法差异
、生态支持
和性能对比
等5
个方面一次理清总结好,下次遇到这种问题直接举一反三。
一、模块基本概念
require
: 是CommonJS
模块规范,主要应用于Node.js
环境。
import
:是ES6
模块规范,主要应用于现代浏览器和现代js
开发(适用于例如各种前端框架)。
二、编译规则
require
:
require
执行时会把导入的模块进行缓存,下次再调用会返回同一个实例。
在CommonJS
模块规范中,require
默认是同步的。当我们在某个模块中使用require
调用时,会等待调用完成才接着往下执行,如下例子所示。
模块A
代码
console.log('我是模块A的1...');
const moduleB = require('./myModuleB');
console.log('我是模块A的2');
模块B
代码
console.log('我是模块B...');
打印顺序,会按顺序同步执行
// 我是模块A的1...
// 我是模块B...
// 我是模块A的2...
注意:
require
并非绝对是同步执行,例如在Webpack
中能使用require.ensure
来进行异步加载模块。
import
:
在ES6
模块规范中,import
默认是静态编译的,也就是在编译过程就已经确认了导入的模块是啥,因此默认
是同步的。import
有引用提升置顶效果,也就是放在何处都会默认在最前面。
但是...., 通过import()
动态引入是异步的哦,并且是在执行中加载的。
import()
在真实业务中是很常见的,例如路由组件的懒加载component: () => import('@/components/dutest.vue')
和动态组件const MyTest = await import('@/components/MyTest.vue');
等等,import()
执行返回的是一个 Promise
,所以经常会配合async/await
一起用。
三、基本用法差异
require
:
一般不直接用于前端框架,是用于 Node.js
环境和一些前端构建工具(例如:Webpack
)中
1. 导入模块(第三方库)
在Node.js
中经常要导入各种模块,用require
可以导入模块是最常见的。例如导入一个os
模块
const os = require('os');
// 使用
os.platform()
2. 导入本地写好的模块
假设我本地项目有一个名为 utils.js
的本地文件,文件里面导出一个add
函数
module.exports = {
add: (a, b) => a + b,
};
在其它文件中导入并使用上面的模块
const { add } = require('../test/utils');
// 使用
add(2, 3);
import
:
一般都是应用于现在浏览器和各种主流前端框架(例如:Vue\react
)
1. 静态引入(项目中最常用)
这种情况一般适用于确定的模块关系,是在编译时解析
<script setup>
import { ref } from 'vue';
import test from '@/components/test.vue';
</script>
2. 动态引入
其实就是使用import()
函数去返回一个 Promise
,在Promise
回调函数里面处理加载相关,例如路由的懒加载。
{
path: '/',
name: 'test',
component: () => import('@/components/dutest.vue')
},
或者动态引入一些文件(或者本地的JSON
文件)
<script setup>
const MyTest = await import('@/components/MyTest.vue');
</script>
四、生态支持
require
:
Node.js14
之前是默认模块系统。目前的浏览器基本是不原生支持 CommonJS
,都是需要通过构建工具(如 Webpack
)转换才行。并且虽然目前市面上CommonJS
依然广泛使用,但基本都是比较老的库,感觉被逐渐过渡了。
import
:
import
是ES6
规范,并且Node.js
在Node.js12
开始支持ES6
,Node.js14
之后是默认选项。目前现代浏览器和主流的框架(Vue、React
)都支持原生ES6
,大多数现代库也是,因此import
是未来主流。
五、性能对比
ES6
支持 Tree Shaking
摇树优化,因此可以更好地去除一些没用的代码,能很好减小打包体积。
所以import
有更好的性能。
import()
能动态导入模块性能更好,而require
不支持动态导入。
小结
对比下来发现,import
不但有更好性能,而且还是Node.js14
之后的默认,会是主流趋势。
至此我感觉足够能举一反三了,如有哪里写的不对或者有更好建议欢迎大佬指点一二啊。
来源:juejin.cn/post/7425135423145394213
深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘
深入解析 effet.js:人脸识别、添加、打卡与睡眠检测的项目结构揭秘
近年来,面部识别和 AR 特效技术的普及让我们在日常应用中越来越多地接触到有趣的互动体验。而基于 facemesh.js 的二次开发框架——effet.js,则为开发者提供了一种简单而强大的方式来实现面部特效和识别功能。今天,我们将通过 effet.js 的项目结构,深入探讨其运行原理,看看它是如何利用前沿技术来实现流畅的人脸检测与交互的。
1. effet.js 的整体架构
effet.js 的项目结构采用模块化设计,通过明确的目录划分,将各项功能进行清晰地组织和封装。我们可以将项目大致分为以下几个部分:
- components:主要包括内部初始化数据、公共枚举以及管理当前 DOM 实例的逻辑。
- core:核心模块,包含动作处理、数据库交互、DOM 操作等多项功能,是整个框架的关键部分。
- styles:存放框架的样式文件,用于定义人脸特效的视觉表现。
- util:各种通用工具函数,包括摄像头操作、条件检测、绘制等常用的辅助功能。
- index.js:整个项目的入口文件,负责初始化和启动框架。
接下来,我们将详细介绍这些模块的作用及其在 effet.js 运行过程中的角色。
2. components 组件模块
components
目录主要用于存放框架的公共组件和初始化数据。
- AppState.ts:管理内部的初始化数据,包括摄像头、DOM 元素等基本信息。
- FaceManager.ts:用于管理当前 DOM 单例,这个类的作用是确保在处理消息替换时,始终对同一 DOM 元素进行操作,避免出现不必要的内存占用和资源浪费。
这种设计让 effet.js 在处理多次人脸检测和特效应用时能够高效、稳定地管理 DOM 元素。
3. core 核心模块
core
目录是 effet.js 的核心逻辑所在,涵盖了以下几个重要部分:
- action:动作处理模块,包含人脸添加、登录检测、睡眠检测和打卡等功能。例如,
addFace/index.js
负责处理用户人脸添加的逻辑,而checkLogin/index.js
则用于人脸登录的检测。 - before:动作前的预处理模块。每个动作在执行前,可能需要一些额外的检查或准备工作。例如,
checkLogin/index.js
用于处理登录前的检查逻辑。 - db:数据库模块,
db.js
负责使用 IndexedDB 来存储和缓存用户数据、模型信息等。这种设计让 effet.js 可以离线运行,提升了用户体验。 - defaultAssign:默认配置和参数分配模块,
assign.js
用于为框架中的各个组件提供初始参数和默认值,确保框架在各种环境下均能正常运行。 - dom:DOM 操作模块,包含创建和管理人脸相关 DOM 元素的逻辑,例如
createFaceElements.js
用于动态创建人脸特效所需的 DOM 元素。 - log:用于屏蔽插件相关的内部警告信息,
log.js
确保了控制台的整洁,方便开发人员进行调试。
核心模块的分层设计使得每个功能都具有独立性和可维护性,这对于复杂交互和特效的实现尤为重要。
代码示例:人脸添加动作
以下是处理人脸添加动作的代码示例:
import { addFace } from './core/action/faceAction';
// 执行人脸添加逻辑
addFace().then(() => {
console.log('人脸添加成功');
}).catch(error => {
console.error('人脸添加失败:', error);
});
上述代码展示了如何调用核心模块中的人脸添加逻辑来实现用户的人脸注册功能。
4. styles 样式模块
styles
目录包含所有与视觉表现相关的文件,用于定义人脸特效的外观。
- template:存放不同功能的样式模板,如
addFace
和checkLogin
等模块中的index.css
定义了对应特效的样式。 - faceColor.js:用于处理与人脸特效颜色相关的逻辑,让开发者可以根据需求自定义特效的颜色效果。
- root.css:全局样式文件,定义了一些基础样式和布局。
样式模块确保了特效在浏览器中展示时具有一致性和视觉吸引力。通过将样式与逻辑分离,开发者可以更方便地对特效的外观进行调整。
图片示例:人脸添加样式
5. util 通用工具模块
util
目录包含了多个工具函数,用于简化常见的任务,例如摄像头操作、距离计算和人脸网格处理。
- cameraAccessUtils.js 和 cameraUtils.js:用于处理摄像头的访问和操作,如获取摄像头权限、切换摄像头等。
- faceMesh.js:负责处理人脸网格的相关逻辑,例如通过 facemesh 模型获取人脸特征点。
- distanceUtils.js 和 drawingUtils.js:用于进行距离计算和绘图操作,这些工具函数在渲染特效时被频繁使用。
这些工具函数的存在,让开发者可以专注于高层次的功能开发,而不必担心底层的细节实现。
代码示例:摄像头访问工具
import { requestCameraAccess } from './util/cameraAccessUtils';
// 请求摄像头权限
requestCameraAccess().then(stream => {
console.log('摄像头权限已获取');
// 使用摄像头流进行进一步处理
}).catch(error => {
console.error('无法获取摄像头权限:', error);
});
6. 使用 IndexedDB 的缓存机制
effet.js 通过 core/db/db.js
使用 IndexedDB 来缓存模型和其他依赖资源,从而减少每次加载的时间。这种设计可以显著提升用户体验,尤其是对于网络环境不稳定的用户。
IndexedDB 是浏览器内置的一个低级 API,它允许我们在用户设备上存储大量结构化数据。在 effet.js 中,我们利用 IndexedDB 缓存 facemesh 模型和用户的面部数据,确保在用户首次加载模型后,后续访问时能够直接从缓存中读取数据,而无需重新下载模型。这不仅提升了加载速度,还降低了对网络的依赖。
代码示例:使用 IndexedDB 缓存模型
// 缓存 facemesh 模型到 IndexedDB
async function cacheModel(modelData) {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readwrite');
const store = transaction.objectStore('models');
store.put(modelData, 'facemeshModel');
}
// 从 IndexedDB 加载模型
async function loadModelFromCache() {
const db = await openIndexedDB('effetModelCache', 1);
const transaction = db.transaction(['models'], 'readonly');
const store = transaction.objectStore('models');
return store.get('facemeshModel');
}
function openIndexedDB(dbName, version) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
通过这样的缓存机制,用户在第一次访问时可能需要加载较长时间,但后续的访问速度会大大提升,从而提供更流畅的用户体验。
7. 核心模块中的预处理逻辑
在 core/before
目录下,包含了许多预处理逻辑,这些逻辑在每个动作执行之前运行,以确保后续的动作能够顺利进行。
- checkLogin/index.js:负责在执行人脸登录之前检查用户的身份信息,例如判断用户是否已经注册或存在于数据库中。
- faceBefore.js:是所有预处理逻辑的入口,通过调用不同的预处理函数,确保每个动作的执行环境和条件都已满足。
这种预处理机制有效地提高了系统的稳定性和安全性。例如,在进行人脸登录时,如果用户的数据尚未注册或缓存不完整,系统将通过预处理逻辑提前捕获这些问题并进行相应的处理。
代码示例:登录前的预处理
import { checkLogin } from './core/before/faceBefore';
// 执行登录前的检查逻辑
checkLogin().then(() => {
console.log('预处理完成,准备执行登录动作');
}).catch(error => {
console.error('登录预处理失败:', error);
});
8. DOM 操作和特效渲染
effet.js 中的 DOM 操作模块负责创建和管理人脸特效相关的 DOM 元素。通过对 DOM 的操作,我们可以将各种特效应用到用户的人脸上,并根据用户面部的变化来实时调整特效的位置和形状。
- createFaceElements.js:用于动态创建用于渲染特效的 DOM 元素。这些元素包括虚拟的眼镜、面具等,通过将它们附加到特定的人脸特征点,可以实现各种视觉特效。
- defaultElement.js:提供了一些默认的 DOM 元素配置,如特效的初始位置、大小和样式等。
代码示例:创建人脸特效元素
import { createFaceElements } from './core/dom/createFaceElements';
// 创建用于渲染特效的 DOM 元素
createFaceElements().then(elements => {
console.log('特效元素创建成功', elements);
}).catch(error => {
console.error('特效元素创建失败:', error);
});
通过将特效元素与人脸特征点绑定,我们可以实现一些有趣的交互。例如,当用户张嘴或眨眼时,系统可以检测到这些动作并触发相应的视觉反馈,从而提升用户体验的互动性。
9. 样式与用户体验
样式模块不仅仅是为了让特效看起来美观,更是为了确保其与用户的操作无缝对接。styles/template
目录下的样式模板被精心设计,以适应不同类型的设备和显示环境。
例如,addFace/index.css
和 checkLogin/index.css
分别定义了人脸添加和登录检测的样式,通过这些样式文件,开发者可以轻松地实现具有一致性且专业的用户界面。
图片示例:多种特效风格
这种模块化的样式管理方式让我们可以快速调整不同特效的外观,同时确保代码的可读性和可维护性。在不断的版本迭代中,样式模块可以独立于逻辑模块进行修改,极大地提高了项目的可扩展性。
10. effet.js 的初始化流程
index.js
作为项目的主要入口,负责初始化 effet.js 的所有模块。在初始化过程中,系统会调用核心模块、样式模块和工具函数,以确保整个框架能够无缝启动。
- initializeEffet:这是一个主函数,负责加载配置、初始化摄像头、检测用户设备是否满足要求,并调用各个核心模块来启动面部检测和特效渲染。
- FaceManager:用于管理初始化后的 DOM 元素,确保在不同的特效之间切换时,DOM 操作能够始终保持一致。
代码示例:框架初始化
import { initializeEffet } from './core/index';
// 初始化 effet.js 框架
initializeEffet().then(() => {
console.log('effet.js 初始化完成');
}).catch(error => {
console.error('初始化失败:', error);
});
通过这样的初始化流程,我们可以确保框架的各个部分能够正确协同工作,从而为用户提供稳定且高质量的体验。
11. 开发与优化的挑战
effet.js 在开发过程中遇到了许多挑战,例如:
- 模型加载时间长:由于 facemesh 模型文件较大,我们使用 IndexedDB 来缓存模型,减少用户每次访问的加载时间。
- 资源丢包与网络不稳定:为了解决网络环境不稳定的问题,我们采用了离线缓存策略,并通过优化加载顺序和减少请求次数来提升加载速度。
- 性能优化:为了保证在中低端设备上的流畅运行,我们对人脸检测和特效渲染进行了大量优化,包括减少计算开销、利用 WebGL 加速等。
这些挑战促使我们不断改进框架的架构和实现方式,以确保 effet.js 在不同的设备和网络环境下都能表现出色。
结语
effet.js 是一个旨在降低面部识别和特效开发门槛的框架,通过模块化的项目结构封装复杂的底层逻辑,让开发者可以专注于创造有趣的互动体验。通过组件、核心模块、样式、工具函数以及缓存机制的有机结合,effet.js 为开发者提供了强大的基础设施来构建各种人脸交互应用。
通过这篇文章,我们展示了 effet.js 的整体架构、人脸检测与特征点定位、特效渲染、交互逻辑实现以及优化挑战,并结合代码示例和图片示例来帮助你更好地理解其运行原理。希望你能够从中获得启发,创造更多有趣的应用!
如果你有任何问题或者想要进一步了解它的应用,欢迎在评论区留言讨论!
这篇博客旨在详细介绍 effet.js 的运行原理和模块化设计,帮助开发者深入了解其工作机制。在未来的开发中,我们期待更多的人参与到这个项目中来,共同探索面部识别和特效技术的更多可能性。
更多资源
- 官方文档:faceeffet.com
- GitHub 仓库:github.com/typsusan/ef…
- Gitee 仓库:gitee.com/susantyp/ef…
来源:juejin.cn/post/7433805918494916618
仿拼多多领红包、金额数字滚动如何实现?
拼多多现金大转盘领取红包后,小数部分有一个数字移动的效果,这个效果怎么做呢?
本文我会告诉你数字移动的原理,并用 React 实现一个 Demo,效果如下:
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
滚动原理
不难想到,数字移动,实质是一串数字列表,在容器内部向上移动。下图就展示了数字从 0 变为 2,又从 2 变为 4 的过程:
但是,金额数字比较特殊,它是可以进位的。举例来说,39.5 变为 39.9 时,小数部分由 5 到 9 需要向上移动;39.9 变为 40.2 时,小数部分由 9 变到 2 时也需要向上移动。
为了做到这个效果,我们需要每次滚动结束之后,重新设置一整串数字。
同样是从 0 变为 2,又从 2 变为 4。下图不同的是,数字变为 2 时,它下方的数字变为了 3、4、5、6、7、8、9、0、1;数字变为 4 时,它下方的数字变为了 5、6、7、8、9、0、1、2、3。
关键布局
了解原理后,我们开始写元素布局。关键布局有 2 个元素:
- 选择框,它可以确认下一个将要变成的数字,我们用它来模拟领取红包之后、金额变化的情况。
- 数字盒子,它包括三部分,带 overflow: hidden 的外层盒子,包裹数字并向上滚动的内层盒子,以及一个个数字。
点击查看代码
const App = function () {
const [options] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const [nums, setNums] = useState([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
return (
<main>
<div className="select-box">
<span>改变数字:</span>
<select>
{
options.map(v => (
<option key={v}>{v}</option>
))
}
</select>
</div>
<div className="num-box">
<div>
{
nums.map(v => (
<div className="num" key={v}>{v}</div>
))
}
</div>
</div>
</main>
)
};
关键逻辑
数字移动的关键逻辑有 3 个:
- 重置数字数组
- 计算移动距离
- 开启关闭动画
重置数字数组
你之前已经知道,如果数组首位是 2,它下面的数字就是 3、4、5、6、7、8、9、0、1。要获取完整的数组,我们可以分为两个步骤:
- 首先,数组首位背后的数字依次加 1,这样数字就变为了 3、4、5、6、7、8、9、10、11;
- 然后,所有大于 9 的数字都减去 10,这样数字就变为了 3、4、5、6、7、8、9、0、1。
点击查看代码
const getNewNums = (next) => {
const newNums = []
for (let i = next; i < next + 10; i++) {
const item = i > 9 ? (i - 10) : i
newNums.push(item)
}
return newNums
}
计算移动距离
你可以用 current 表示当前的数字,next 表示需要变成的数字。计算移动距离时,需要分两种情况考虑:
- next 大于 current 时,只需要移动
next - current
个数字即可; - next 小于 current 时,需要先移动
10 - next
个数字,再移动 current 个数字即可。
点击查看代码
const calculateDistance = (current, next) => {
const height = 40
let diff = next - current
if (next < current) {
diff = 10 - current + next
}
return -(diff * height)
}
开启关闭动画
不难想到,我们数字移动的动画是使用 translateY 和 transition 实现。当数字移动时,我们把 translateY 设置为 calculateDistance 的结果;当移动结束、重置数组时,我们需要把 translateY 设置为 0。
整个过程中,如果我们一直开启动画,效果会是数字先向上移动,再向下移动,这并不符合预期。
因此,我们需要在数字开始移动时开启动画,数字结束移动后、重置数组前关闭动画。
点击查看代码
const App = function () {
// ... 省略
const numBoxRef = useRef()
const onChange = (e) => {
// 开启动画
numBoxRef.current.style.transition = `all 1s`
// ... 省略
}
const onTransitionEnd = () => {
// 关闭动画
numBoxRef.current.style.transition = ''
// ... 省略
}
return (
<main>{/* ... 省略 */}</main>
)
};
完整代码
总结
本文介绍了类似拼多多的金额数字滚动如何实现,其中有三个关键点,分别是重置数字数组、计算移动距离和开启关闭动画。
拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的 H5、小程序武学。
来源:juejin.cn/post/7430861008753688576