面试官:GROUP BY和DISTINCT有什么区别?
在 MySQL 中,GR0UP BY 和 DISTINCT 都是用来处理查询结果中的重复数据,并且在官方的描述文档中也可以看出:在大多数情况下 DISTINCT 是特殊的 GR0UP BY,如下图所示:
官方文档地址:dev.mysql.com/doc/refman/…
但二者还是有一些细微的不同,接下来一起来看。
1.DISTINCT 介绍
- 用途:DISTINCT 用于从查询结果中去除重复的行,确保返回的结果集中每一行都是唯一的。
- 语法:通常用于 SELECT 语句中,紧跟在 SELECT 关键字之后。例如以下 SQL:
SELECT DISTINCT column1, column2 FROM table_name;
- 工作机制:DISTINCT 会对整个结果集进行去重,即只要结果集中的某一行与另一行完全相同,就会被去除。
2.GR0UP BY 介绍
- 用途:GR0UP BY 主要用于对结果集按照一个或多个列进行分组,通常与聚合函数(如 COUNT, SUM, AVG, MAX, MIN 等)一起使用,以便对每个组进行统计。
- 语法:GR0UP BY 通常跟在 FROM 或 WHERE 子句之后,在 SELECT 语句的末尾部分。例如以下 SQL:
SELECT column1, COUNT(*) FROM table_name GR0UP BY column1;
- 工作机制:GR0UP BY 将数据按指定的列进行分组,每个组返回一行数据。
3.举例说明
3.1 使用 DISTINCT
假设有一个表 students,包含以下数据:
id | name | age |
---|---|---|
1 | Alice | 20 |
2 | Bob | 22 |
3 | Alice | 20 |
使用 DISTINCT 去除重复行:
SELECT DISTINCT name, age FROM students;
结果:
name | age |
---|---|
Alice | 20 |
Bob | 22 |
3.2 使用 GR0UP BY
假设还是上面的表 students,我们想要统计每个学生的数量:
SELECT name, COUNT(*) AS count FROM students GR0UP BY name;
结果:
name | count |
---|---|
Alice | 2 |
Bob | 1 |
4.主要区别
- 功能不同:DISTINCT 用于去除重复行,而 GR0UP BY 用于对结果集进行分组,通常与聚合函数一起使用。
- 返回结果不同:DISTINCT 返回去重后的结果集,查询结果集中只能包含去重的列信息,有其他列信息会报错;GR0UP BY 返回按指定列分组后的结果集,可以展示多列信息,并可以包含聚合函数的计算结果。
- 应用场景不同:DISTINCT 更适合单纯的去重需求,GR0UP BY 更适合分组统计需求。
- 性能略有不同:如果去重的字段有索引,那么 GR0UP BY 和 DISTINCT 都可以使用索引,此情况它们的性能是相同的;而当去重的字段没有索引时,DISTINCT 的性能就会高于 GR0UP BY,因为在 MySQL 8.0 之前,GR0UP BY 有一个隐藏的功能会进行默认的排序,这样就会触发 filesort 从而导致查询性能降低。
课后思考
count(*)、count(1) 和 count(字段) 有什么区别?
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。
来源:juejin.cn/post/7415914114650685481
2024年的安卓现代开发
大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀
如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.
免责声明
📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.
🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.
Kotlin 无处不在 ❤️
Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.
无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.
请查看Kotlin 官方文档
Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.
KotlinConf ‘23
Kotlin 2.0 要来了
另一个需要强调的重要事件是Kotlin 2.0
的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4
新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.
请查看 KotlinConf '23 的回顾, 你可以找到更多信息.
Compose 🚀
Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.
Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.
Jetpack Compose 的一些主要功能包括
- 声明式UI
- 可定制的小部件
- 与现有代码(旧视图系统)轻松集成
- 实时预览
- 改进的性能.
资源:
- Jetpack Compose 文档
- Compose 与 Kotlin 的兼容性图谱
- Jetpack Compose 路线图
- 课程
- Jetpack Compose 中
@Composable
的API 指南
Android Jetpack ⚙️
Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.
其中最常用的工具有:
Material You / Material Design 🥰
Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.
目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.
代码仓库
SplashScreen API
Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.
Clean架构
Clean架构
的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.
特点
- 独立于框架.
- 可测试.
- 独立于UI
- 独立于数据库
- 独立于任何外部机构.
依赖规则
作者在他的博文Clean代码中很好地描述了依赖规则.
依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.
- 博文Clean代码
安卓中的Clean架构
:
Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.
Presentation层的架构模式
架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.
在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:
- MVVM
- MVI
我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅
此外, 你还可以查看应用架构指南.
依赖注入
依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.
模块化
模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.
模块化的优势
可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.
严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.
自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.
可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.
易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.
易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.
改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.
改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.
构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.
更多信息请参阅官方文档.
网络
序列化
在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.
Moshi 和 Kotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.
图像加载
要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.
_ 官方安卓文档
响应/线程管理
说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.
对于新项目, 请始终选择Kotlin协程
❤️. 可以在这里探索一些Kotlin协程相关的概念.
本地存储
在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.
建议:
- S̶h̶a̶r̶e̶d̶P̶r̶e̶f̶e̶r̶e̶n̶c̶e̶s̶
- DataStore
- EncryptedSharedPreferences
测试 🕵🏼
软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::
截屏测试 📸
Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.
R8 优化
R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard
规则文件禁用某些任务或自定义 R8 的行为.
- 代码缩减
- 缩减资源
- 混淆
- 优化
第三方工具
- DexGuard
Play 特性交付
Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.
自适应布局
随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, Medium和Expanded.
Window Size Class
我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.
其他相关资源
本地化 🌎
本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.
注: BCP 47 是安卓系统使用的国际化标准.
参考资料
性能 🔋⚙️
在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:
应用内更新
当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.
运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.
- 应用内更新文档
应用内评论
Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.
一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.
*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论和评论提示的设计的规定.
- 应用内评论文档
可观察性 👀
在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.
工具
辅助功能
辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.
考虑因素:
- 增加文字的可视性(颜色对比度, 可调整文字大小)
- 使用大而简单的控件
- 描述每个UI元素
更多详情请查看辅助功能 - Android 文档
安全性 🔐
在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.
- 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.
- 加密敏感数据和文件: 使用EncryptedSharedPreferences 和EncryptedFile.
- 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />
- 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用
local.properties
. - 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.
res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>
- 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:
- 代码混淆.
- 根检测.
- 篡改/应用钩子检测.
- 防止逆向工程攻击.
- 反调试技术.
- 虚拟环境检测
- 应用行为的运行时分析.
想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.
版本目录
Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.
优点:
- 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.
- 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.
- 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".
- 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.
请查看更多信息
Secret Gradle 插件
Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties
文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.
日志
日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.
Linter / 静态代码分析器
Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.
Google Play Instant
Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用和即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.
新设计中心
安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.
点击查看新的设计中心
人工智能
Gemini
和PalM 2
是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.
人工智能编码助手工具
Studio Bot
Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.
Github Copilot
GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.
Amazon CodeWhisperer
这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.
Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀
最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀
如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:
来源:juejin.cn/post/7342861726000791603
Java 8 魔法:利用 Function 接口告别冗余代码,打造高效断言神器
前言
在 Java
开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8
带来了函数式编程的春风,以 Function
接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用场景为例,即使用 Java 8
的函数式编程特性来重构数据有效性断言逻辑,展示如何通过 SFunction
(基于 Java 8
的 Lambda
表达式封装)减少代码重复,从而提升代码的优雅性和可维护性。
背景故事:数据校验的烦恼
想象一下,在一个复杂的业务系统中,我们可能需要频繁地验证数据库中某个字段值是否有效,是否符合预期值。传统的做法可能充斥着大量相似的查询逻辑,每次都需要手动构建查询条件、执行查询并处理结果,这样的代码既冗长又难以维护。
例如以下两个验证用户 ID 和部门 ID 是否有效的方法,虽然简单,但每次需要校验不同实体或不同条件时,就需要复制粘贴并做相应修改,导致代码库中充满了大量雷同的校验逻辑,给维护带来了困扰。
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
Java 8 的魔法棒:函数式接口
Java 8 引入了函数式接口的概念,其中 Function<T, R>
是最基础的代表,它接受一个类型 T
的输入,返回类型 R
的结果。而在 MyBatis Plus
等框架中常用的 SFunction
是对 Lambda
表达式的进一步封装,使得我们可以更加灵活地操作实体类的属性。
实战演练:重构断言方法
下面的 ensureColumnValueValid
方法正是利用了函数式接口的魅力,实现了对任意实体类指定列值的有效性断言:
/**
* 确认数据库字段值有效(通用)
*
* @param <V> 待验证值的类型
* @param valueToCheck 待验证的值
* @param columnExtractor 实体类属性提取函数
* @param queryExecutor 单条数据查询执行器
* @param errorMessage 异常提示信息模板
*/
public static <T, R, V> void ensureColumnValueValid(V valueToCheck, SFunction<T, R> columnExtractor, SFunction<LambdaQueryWrapper<T>, T> queryExecutor, String errorMessage) {
if (valueToCheck == null) return;
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(columnExtractor);
wrapper.eq(columnExtractor, valueToCheck);
wrapper.last("LIMIT 1");
T entity = queryExecutor.apply(wrapper);
R columnValue = columnExtractor.apply(entity);
if (entity == null || columnValue == null)
throw new DataValidationException(String.format(errorMessage, valueToCheck));
}
这个方法接受一个待验证的值、一个实体类属性提取函数、一个单行数据查询执行器和一个异常信息模板作为参数。通过这四个参数,不仅能够进行针对特定属性的有效性检查,而且还能生成具有一致性的异常信息。
对比分析
使用 Function
改造前
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
使用 Function
改造后
public void assignTaskToUser(AddOrderDTO dto) {
ensureColumnValueValid(dto.getUserId(), User::getId, userDao::getOne, "用户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getCustomerId(), Customer::getId, customerDao::getOne, "客户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getSupplieId(), Supplie::getId, supplierDao::getOne, "供应商ID无效");
// 现在可以确信客户存在
Customer cus = customerDao.findById(dto.getCustomerId());
// 创建订单的逻辑...
}
对比上述两段代码,我们发现后者不仅大幅减少了代码量,而且通过函数式编程,表达出更为清晰的逻辑意图,可读性和可维护性都有所提高。
优点
- 减少重复代码: 通过
ensureColumnValueValid
方法,所有涉及数据库字段值有效性检查的地方都可以复用相同的逻辑,将变化的部分作为参数传递,大大减少了因特定校验逻辑而产生的代码量。 - 增强代码复用: 抽象化的校验方法适用于多种场景,无论是用户ID、订单号还是其他任何实体属性的校验,一套逻辑即可应对。
- 提升可读性和维护性: 通过清晰的函数签名和 Lambda 表达式,代码意图一目了然,降低了后续维护的成本。
- 灵活性和扩展性: 当校验规则发生变化时,只需要调整
ensureColumnValueValid
方法或其内部实现,所有调用该方法的地方都会自动受益,提高了系统的灵活性和扩展性。
举一反三:拓展校验逻辑的边界
通过上述的实践,我们见识到了函数式编程在简化数据校验逻辑方面的威力。但这只是冰山一角,我们可以根据不同的业务场景,继续扩展和完善校验逻辑,实现更多样化的校验需求。以下两个示例展示了如何在原有基础上进一步深化,实现更复杂的数据比较和验证功能。
断言指定列值等于预期值
首先,考虑一个场景:除了验证数据的存在性,我们还需确认查询到的某列值是否与预期值相符。这在验证用户角色、状态变更等场景中尤为常见。为此,我们设计了 validateColumnValueMatchesExpected
方法:
/**
* 验证查询结果中指定列的值是否与预期值匹配
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValue 期望的列值
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值与预期值不匹配时抛出异常
*/
public static <T, R, C> void validateColumnValueMatchesExpected(
SFunction<T, R> targetColumn, R expectedValue,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
// 创建查询包装器,选择目标列并设置查询条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
// 执行查询方法
T one = queryMethod.apply(wrapper);
// 如果查询结果为空,则直接返回,视为验证通过(或忽略)
if (one == null) return;
// 获取查询结果中目标列的实际值
R actualValue = targetColumn.apply(one);
// 比较实际值与预期值是否匹配,这里假设notMatch是一个自定义方法用于比较不匹配情况
boolean doesNotMatch = notMatch(actualValue, expectedValue);
if (doesNotMatch) {
// 若不匹配,则根据错误信息模板抛出异常
throw new RuntimeException(String.format(errorMessage, expectedValue, actualValue));
}
}
// 假设的辅助方法,用于比较值是否不匹配,根据实际需要实现
private static <R> boolean notMatch(R actual, R expected) {
// 示例简单实现为不相等判断,实际情况可能更复杂
return !Objects.equals(actual, expected);
}
这个方法允许我们指定一个查询目标列(targetColumn)、预期值(expectedValue)、查询条件列(conditionColumn)及其对应的条件值(conditionValue),并提供一个查询方法(queryMethod)来执行查询。如果查询到的列值与预期不符,则抛出异常,错误信息通过 errorMessage 参数定制。
应用场景:
例如在一个权限管理系统中,当需要更新用户角色时,系统需要确保当前用户的角色在更新前是 “普通用户”,才能将其升级为 “管理员”。此场景下,可以使用 validateColumnValueMatchesExpected
方法来验证用户当前的角色是否确实为“普通用户”。
// 当用户角色不是 “普通用户” 时抛异常
validateColumnValueMatchesExpected(User::getRoleType, "普通用户", User::getId, userId, userMapper::getOne, "用户角色不是普通用户,无法升级为管理员!");
断言指定值位于期望值列表内
进一步,某些情况下我们需要验证查询结果中的某一列值是否属于一个预设的值集合。例如,验证用户角色是否合法。为此,我们创建了 validateColumnValueMatchesExpectedList
方法:
/**
* 验证查询结果中指定列的值是否位于预期值列表内
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValueList 期望值的列表
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值不在预期值列表内时抛出异常
*/
public static <T, R, C> void validateColumnValueInExpectedList(
SFunction<T, R> targetColumn, List<R> expectedValueList,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
T one = queryMethod.apply(wrapper);
if (one == null) return;
R actualValue = targetColumn.apply(one);
if (actualValue == null) throw new RuntimeException("列查询结果为空");
if (!expectedValueList.contains(actualValue)) {
throw new RuntimeException(errorMessage);
}
}
这个方法接受一个目标列(targetColumn)、一个预期值列表(expectedValueList)、查询条件列(conditionColumn)及其条件值(conditionValue),同样需要一个查询方法(queryMethod)。如果查询到的列值不在预期值列表中,则触发异常。
应用场景: 在一个电商平台的订单处理流程中,系统需要验证订单状态是否处于可取消的状态列表里(如 “待支付”、“待发货”)才允许用户取消订单。此时,validateColumnValueInExpectedList
方法能有效确保操作的合法性。
// 假设 OrderStatusEnum 枚举了所有可能的订单状态,cancelableStatuses 包含可取消的状态
List<String> cancelableStatuses = Arrays.asList(OrderStatusEnum.WAITING_PAYMENT.getValue(), OrderStatusEnum.WAITING_DELIVERY.getValue());
// 验证订单状态是否在可取消状态列表内
validateColumnValueInExpectedList(Order::getStatus, cancelableStatuses, Order::getOrderId, orderId, orderMapper::selectOne, "订单当前状态不允许取消!");
通过这两个扩展方法,我们不仅巩固了函数式编程在减少代码重复、提升代码灵活性方面的优势,还进一步证明了通过抽象和泛型设计,可以轻松应对各种复杂的业务校验需求,使代码更加贴近业务逻辑,易于理解和维护。
核心优势
- 代码复用:通过泛型和函数式接口,该方法能够适应任何实体类和属性的校验需求,大大减少了重复的查询逻辑代码。
- 清晰表达意图:方法签名直观表达了校验逻辑的目的,提高了代码的可读性和可维护性。
- 灵活性:使用者只需提供几个简单的 Lambda 表达式,即可完成复杂的查询逻辑配置,无需关心底层实现细节。
- 易于维护与扩展:
- 当需要增加新的实体验证时,仅需调用
ensureColumnValueValid
并传入相应的参数,无需编写新的验证逻辑,降低了维护成本。 - 修改验证规则时,只需调整
ensureColumnValueValid
内部实现,所有调用处自动遵循新规则,便于统一管理。 - 异常处理集中于
ensureColumnValueValid
方法内部,统一了异常抛出行为,避免了在多个地方处理相同的逻辑错误,减少了潜在的错误源。
- 当需要增加新的实体验证时,仅需调用
函数式编程的力量
通过这个实例,我们见证了函数式编程在简化代码、提高抽象层次上的强大能力。在 Java 8 及之后的版本中,拥抱函数式编程思想,不仅能够使我们的代码更加简洁、灵活,还能在一定程度上促进代码的正确性和可测试性。因此,无论是日常开发还是系统设计,都值得我们深入探索和应用这一现代编程范式,让代码如魔法般优雅而高效。
来源:juejin.cn/post/7384256110280572980
让同事用Cesium写一个测量工具并支持三角测量,他说有点难。。
大家好,我是日拱一卒的攻城师不浪,致力于技术与艺术的融合。这是2024年输出的第39/100篇文章。
可视化&Webgis交流群+V:brown_7778(备注来意)
前言
最近在开发智慧城市的项目,产品想让同事基于Cesium
开发一个测量工具,需要支持长度测量
、面积测量
以及三角测量
,但同事挠了挠头,说做这个有点费劲,还反问了产品:做这功能有啥意义?
产品经理:测量工具在智慧城市中发挥了重要的作用,通过对城市道路,地形,建筑物,场地等的精确测量,确保施工规划能够与现实场景精准吻合,节省人力以及施工成本。
对桥梁、隧道、地铁、管网等城市基础设施进行结构健康监测,安装传感器,实时监测结构体震动以及结构体偏移量
等数据,确保设施安全运行并能够提前发现问题,防患于未然。
开发同事听完,觉得还蛮有道理,看向我:浪浪,如何应对?
我:呐,拿走直接抄!下班请吃铜锅涮肉!
三角测量
先来了解下三角测量
:是一种基于三角形
几何原理的测量方法,用于确定未知点的位置。它通过已知基线(即两个已知点之间的距离)和从这两个已知点测量的角
度,计算出目标点的精确位置。
例如在建筑施工中,工程师使用三角测量法来测量楼体高度
、桥梁等结构的位置
和角度
,确保建筑的精准施工。
代码解析
接下来看下这个MeasureTool
类,主要包含以下功能:
- 坐标转换:整理了地理坐标(WGS84)与笛卡尔坐标(Cartesian)之间的转换功能。
- 拾取功能:通过屏幕坐标拾取场景中的三维位置,并判断该位置是位于模型上、地形上还是椭球体表面。
- 距离测量:绘制线段,并在场景中显示起点和终点之间的距离。
- 面积测量:通过给定的一组坐标,计算它们组成的多边形面积。
- 三角测量:绘制一个三角形来测量水平距离、直线距离和高度差。
坐标转换功能
transformWGS84ToCartesian
: 将WGS84坐标(经度、纬度、高度)转换为Cesium中的三维笛卡尔坐标。transformCartesianToWGS84
: 将Cesium的三维笛卡尔坐标转换为WGS84坐标。
核心代码:
transformWGS84ToCartesian(position, alt) {
return position
? Cesium.Cartesian3.fromDegrees(
position.lng || position.lon,
position.lat,
(position.alt = alt || position.alt),
Cesium.Ellipsoid.WGS84
)
: Cesium.Cartesian3.ZERO;
}
transformCartesianToWGS84(cartesian) {
var ellipsoid = Cesium.Ellipsoid.WGS84;
var cartographic = ellipsoid.cartesianToCartographic(cartesian);
return {
lng: Cesium.Math.toDegrees(cartographic.longitude),
lat: Cesium.Math.toDegrees(cartographic.latitude),
alt: cartographic.height,
};
}
Cesium的Cartesian3.fromDegrees
和Ellipsoid.WGS84.cartesianToCartographic
方法分别用于实现经纬度与笛卡尔坐标系的相互转换。
拾取功能
拾取功能允许通过屏幕像素坐标来获取3D场景中的位置。主要依赖scene.pickPosition
和scene.globe.pick
来实现拾取。
核心代码:
getCatesian3FromPX(px) {
var picks = this._viewer.scene.drillPick(px);
var cartesian = this._viewer.scene.pickPosition(px);
if (!cartesian) {
var ray = this._viewer.scene.camera.getPickRay(px);
cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene);
}
return cartesian;
}
这里首先尝试从3D模型或地形上拾取位置,如果未能拾取到模型或地形上的点,则尝试通过射线投射到椭球体表面。
距离测量
通过拾取点并记录每个点的坐标,计算相邻两个点的距离,并显示在Cesium场景中。通过ScreenSpaceEventHandler
来捕获鼠标点击和移动事件。
核心代码:
drawLineMeasureGraphics(options = {}) {
var positions = [];
var _handlers = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.position);
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
_handlers.setInputAction(function (movement) {
var cartesian = this.getCatesian3FromPX(movement.endPosition);
positions.pop();
positions.push(cartesian);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
_handlers.setInputAction(function () {
_handlers.destroy();
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
}
测距的基本思想是通过鼠标点击获取多个点的坐标,然后计算每两个相邻点的距离。
面积测量
面积测量通过计算多个点围成的多边形的面积,基于Cesium的PolygonHierarchy
实现多边形绘制。
核心代码:
getPositionsArea(positions) {
let ellipsoid = Cesium.Ellipsoid.WGS84;
let area = 0;
positions.push(positions[0]); // 闭合多边形
for (let i = 1; i < positions.length; i++) {
let p1 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i - 1]));
let p2 = ellipsoid.cartographicToCartesian(this.transformWGS84ToCartographic(positions[i]));
area += p1.x * p2.y - p2.x * p1.y;
}
return Math.abs(area) / 2.0;
}
这里通过一个简单的多边形面积公式(叉乘)来计算笛卡尔坐标下的面积。
三角测量
三角测量通过拾取三个点,计算它们之间的直线距离
、水平距离
以及高度差
,构建一个三角形并在场景中显示这些信息。
核心代码:
drawTrianglesMeasureGraphics(options = {}) {
var _positions = [];
var _handler = new Cesium.ScreenSpaceEventHandler(this._viewer.scene.canvas);
_handler.setInputAction(function (movement) {
var position = this.getCatesian3FromPX(movement.position);
_positions.push(position);
if (_positions.length === 3) _handler.destroy();
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
该方法核心思想是获取三个点的坐标,通过高度差来构建水平线和垂线,然后显示相应的距离和高度差信息。
使用
封装好,之后,使用起来就非常简单了。
import MeasureTool from "@/utils/cesiumCtrl/measure.js";
const measure = new MeasureTool(viewer);
**
* 测距
*/
const onLineMeasure = () => {
measure.drawLineMeasureGraphics({
clampToGround: true,
callback: (e) => {
console.log("----", e);
},
});
};
/**
* 测面积
*/
const onAreaMeasure = () => {
measure.drawAreaMeasureGraphics({
clampToGround: true,
callback: () => {},
});
};
/**
* 三角量测
*/
const onTrianglesMeasure = () => {
measure.drawTrianglesMeasureGraphics({
callback: () => {},
});
};
最后
这些测量工具都是依赖于Cesium提供的坐标转换、拾取以及事件处理机制,核心思路是通过ScreenSpaceEventHandler
捕捉鼠标事件,获取坐标点,并通过几何算法计算距离、面积和高度。
【完整源码
地址】:github.com/tingyuxuan2…
如果认为有帮助,希望可以给我们一个免费的star
,激励我们持续开源更多代码。
如果想系统学习Cesium,可以看下作者的Cesium系列教程
《Cesium从入门到实战》
,将Cesium的知识点进行串联,让不了解Cesium的小伙伴拥有一个完整的学习路线,学完后直接上手做项目,+作者:brown_7778(备注来意)了解课程细节。
另外有需要进
可视化&Webgis交流群
可以加我:brown_7778(备注来意),也欢迎数字孪生可视化领域
的交流合作。
来源:juejin.cn/post/7424902468243669029
因离线地图引发的惨案
小王最近接到了一个重要的需求,要求在现有系统上实现离线地图功能,并根据不同的经纬度在地图上显示相应的标记(marks)。老板承诺,如果小王能够顺利完成这个任务,他的年终奖就有着落了。
为了不辜负老板的期望并确保自己能够拿到年终奖,小王开始了马不停蹄的开发工作。他查阅了大量文档,研究了各种离线地图解决方案,并一一尝试。经过48小时的连续奋战,凭借着顽强的毅力和专业的技术能力,小王终于成功完成了需求。
他在系统中集成了离线地图,并实现了根据经纬度显示不同区域标记的功能。每个标记都能准确地反映地理位置的信息,系统的用户体验得到了极大的提升。小王的心中充满了成就感和对未来奖励的期待。
然而,天有不测风云。当小王准备向老板汇报工作成果时,却得知一个令人震惊的消息:老板因涉嫌某些违法行为(爬取不当得利)被逮捕了,公司也陷入了一片混乱。年终奖的承诺随之泡汤,甚至连公司未来的发展都蒙上了一层阴影。
尽管如此,小王并没有因此而气馁。这次通过技术让老板成功的获得了编制,他深知只有不断技术的积累和经验的增长才能更好的保护老板。
1.离线地图
首先需要怎么做呢,你需要一个地图瓦片生成器(爬取谷歌、高德、百度等各个平台的地图瓦片,其实就是一张张缩小的图片,这里爬取可以用各种技术手段,但是违法偶,老板就是这么进去的),有个工具推荐:
链接:pan.baidu.com/s/1nflY8-KL…
提取码:yqey
下载解压打开下面的文件
打开了界面就长这样
可以调整瓦片样式
下载速度龟慢,建议开启代理,因为瓦片等级越高数量越多,需要下载的包越大,这里建议下载到11-16级别,根据自己需求
下载完瓦片会保存在自己定义的文件夹,这里不建议放在c盘,会生成以下文件
使用一个文件服务去启动瓦片额静态服务,可以使用http-server
安装http-server
yarn add http-server -g
cd到下载的mapabc目录下
http-server roadmap
本地可以这么做上线后需要使用nginx代理这个静态服务
server {
listen 80;
server_name yourdomain.com; # 替换为你的域名或服务器 IP
root /var/www/myapp/public; # 设置根目录
index index.html; # 设置默认文件
location / {
try_files $uri $uri/ =404;
}
# 配置访问 roadmap 目录下的地图瓦片
location ^~/roloadmap/{
alias /home/d5000/iot/web/roloadmap/;
autoindex on; # 如果你想列出目录内容,可以开启这个选项
}
# 配置其他静态文件的访问(可选)
location /static/ {
alias /var/www/myapp/public/static/;
}
# 其他配置,例如反向代理到应用服务器等
# location /api/ {
# proxy_pass http://localhost:3000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
}
配置完重启一下ngix即可
对于如何将瓦片结合成一张地图并在vue2中使用,这里采用vueLeaflet,它是在leaflet基础上进行封装的
这个插件需要安装一系列包
yarn add leaflet vue2-leaflet leaflet.markercluster
<l-tile-layer url="http://192.168.88.211:8080/{z}/{x}/{y}.png" ></l-tile-layer>
这里的url就是上面启动的服务,包括端口和ip,要能访问到瓦片
编写代码很简单
<template>
<div class="map">
<div class="search">
<map-search @input_val="inputVal" @select_val="selectVal" />
</div>
<div class="map_container">
<l-map
:zoom="zoom"
:center="center"
:max-bounds="bounds"
:min-zoom="9"
:max-zoom="15"
:key="`${center[0]}-${center[1]}-${zoom}`"
style="height: 100vh; width: 100%"
>
<l-tile-layer
url="http://192.168.88.211:8080/{z}/{x}/{y}.png"
></l-tile-layer>
<l-marker-cluster>
<l-marker
v-for="(marker, index) in markers"
:key="index"
:lat-lng="marker.latlng"
:icon="customIcon"
@click="handleMarkerClick(marker)"
>
<l-tooltip :offset="tooltipOffset">
<div class="popup-content">
<p>设备名称: {{ marker.regionName }}</p>
<p>主线设备数量: {{ marker.endNum }}</p>
<p>边缘设备数量: {{ marker.edgNum }}</p>
</div>
</l-tooltip>
</l-marker>
</l-marker-cluster>
</l-map>
</div>
</div>
</template>
<script>
import { LMap, LTileLayer, LMarker, LPopup, LTooltip, LMarkerCluster } from "vue2-leaflet";
import mapSearch from "./search.vue";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
// import geojsonData from "./city.json"; // 确保这个路径是正确的
import geoRegionData from "./equip.json"; // 确保这个路径是正确的
// 移除默认的图标路径
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
export default {
name: "Map",
components: {
LMap,
LTileLayer,
LMarker,
LPopup,
LTooltip,
mapSearch,
LMarkerCluster
},
data() {
return {
zoom: 9,
center: [32.0617, 118.7636], // 江苏省的中心坐标
bounds: [
[30.7, 116.3],
[35.1, 122.3],
], // 江苏省的地理边界
markers: geoRegionData,
customIcon: L.icon({
iconUrl: require("./equip.png"), // 自定义图标的路径
iconSize: [21, 27], // 图标大小
iconAnchor: [12, 41], // 图标锚点
popupAnchor: [1, -34], // 弹出框相对于图标的锚点
shadowSize: [41, 41], // 阴影大小(如果有)
shadowAnchor: [12, 41], // 阴影锚点(如果有)
}),
tooltipOffset: L.point(10, 10), // 调整偏移值
};
},
methods: {
inputVal(val) {
// 处理输入值变化
this.center = val;
this.zoom = 15;
},
selectVal(val) {
// 处理选择值变化
this.center = val;
this.zoom = 15;
},
handleMarkerClick(marker) {
this.center = marker.latlng;
this.zoom = 15;
},
},
};
</script>
<style scoped lang="less">
@import "~leaflet/dist/leaflet.css";
@import "~leaflet.markercluster/dist/MarkerCluster.css";
@import "~leaflet.markercluster/dist/MarkerCluster.Default.css";
.map {
width: 100%;
height: 100%;
position: relative;
.search {
position: absolute;
z-index: 1000;
left: 20px;
top: 10px;
padding: 10px; /* 设置内边距 */
}
}
.popup-content {
font-family: Arial, sans-serif;
text-align: left;
}
.popup-content h3 {
margin: 0;
font-size: 16px;
font-weight: bold;
}
.popup-content p {
margin: 4px 0;
font-size: 14px;
}
/deep/.leaflet-control {
display: none !important; /* 隐藏默认控件 */
}
/deep/.leaflet-control-zoom {
display: none !important; /* 隐藏默认控件 */
}
</style>
这里使用遇到一个坑,需要切换地图中心center,需要给l-map绑定一个key="${center[0]}-${center[1]}-${zoom}
",不然每次切换第一次会失败,第二次才能成功
可以给行政区添加范围,这里需要geojson数据,可以在阿里云数据平台上获取
通过组件加载即可
<l-geo-json :geojson="geojson"></l-geo-json>
效果如下
以上方法,不建议使用,如果是商业使用,不建议使用,不然容易被告侵权,最好能是使用官方合法的地图api,例如谷歌、百度、腾讯、高德,这里我使用高德api给兄弟们们展示一下
2.高德在线地图
2.1首先需要在高德的开放平台申请一个账号
创建一个项目,如下,我们需要使用到这个key和密钥,这里如果是公司使用可以使用公司的信息注册一个账号,公司的账号权限高于个人,具体区别如下参看官网
developer.amap.com/api/faq/acc…
2.2如何在框架中使用
因为不想在创建一个react应用了,这里还是用vue2演示,vue2需要下载一个高德提供的npm包
yarn add @amap/amap-jsapi-loader
编写代码
<template>
<div class="map">
<div class="serach">
<map-search @share_id="shareId" @input_val="inputVal" @select_val="selectVal" @change_theme="changeTheme" />
</div>
<div class="map_container" id="container"></div>
</div>
</template>
<script>
import AMapLoader from "@amap/amap-jsapi-loader";
import mapSearch from "./search.vue";
import cityJson from "../../assets/area.json";
window._AMapSecurityConfig = {
//这里是高德开放平台创建项目时生成的密钥
securityJsCode: "xxxx",
};
export default {
name: "mapContainer",
components: { mapSearch },
mixins: [],
props: {},
data() {
return {
map: null,
autoOptions: {
input: "",
},
auto: null,
AMap: null,
placeSearch: null,
searchPlaceInput: "",
polygons: [],
positions: [],
//地图样式配置
inintMapStyleConfig: {
//设置地图容器id
viewMode: "3D", //是否为3D地图模式
zoom: 15, //初始化地图级别
rotateEnable: true, //是否开启地图旋转交互 鼠标右键 + 鼠标画圈移动 或 键盘Ctrl + 鼠标左键画圈移动
pitchEnable: true, //是否开启地图倾斜交互 鼠标右键 + 鼠标上下移动或键盘Ctrl + 鼠标左键上下移动
mapStyle: "amap://styles/whitesmoke", //设置地图的显示样式
center: [118.796877, 32.060255], //初始化地图中心点位置
},
//地图配置
mapConfig: {
key: "xxxxx", // 申请好的Web端开发者Key,首次调用 load 时必填
version: "2.0", // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: [
"AMap.AutoComplete",
"AMap.PlaceSearch",
"AMap.Geocoder",
"AMap.DistrictSearch",
], // 需要使用的的插件列表,如比例尺'AMap.Scale'等
},
// 实例化DistrictSearch配置
districtSearchOpt: {
subdistrict: 1, //获取边界不需要返回下级行政区
extensions: "all", //返回行政区边界坐标组等具体信息
},
//这里是mark中的设置
icon: {
type: "image",
image: require("../../assets/equip.png"),
size: [15, 21],
anchor: "bottom-center",
fitZoom: [14, 20], // Adjust the fitZoom range for scaling
scaleFactor: 2, // Zoom scale factor
maxScale: 2, // Maximum scale
minScale: 1 // Minimum scale
}
};
},
created() {
this.initMap();
},
methods: {
//初始化地图
async initMap() {
this.AMap = await AMapLoader.load(this.mapConfig);
this.map = new AMap.Map("container", this.inintMapStyleConfig);
//根据地理位置查询经纬度
this.positions = await Promise.all(cityJson.map(async item => {
try {
const dot = await this.queryGeocodes(item.cityName, this.AMap);
return {
...item,
dot: dot
};
} catch (error) {
}
}));
//poi查询
this.addMarker();
//显示安徽省的区域
this.drawBounds("安徽省");
},
//查询地理位置
async queryGeocodes(newValue, AMap) {
return new Promise((resolve, reject) => {
//加载行政区划插件
const geocoder = new AMap.Geocoder({
// 指定返回详细地址信息,默认值为true
extensions: 'all'
});
// 使用地址进行地理编码
geocoder.getLocation(newValue, (status, result) => {
if (status === 'complete' && result.geocodes.length) {
const geocode = result.geocodes[0];
const latitude = geocode.location.lat;
const longitude = geocode.location.lng;
resolve([longitude, latitude]);
} else {
reject('无法获取该地址的经纬度');
}
});
});
},
//结合输入提示进行POI搜索
shareId(val) {
this.autoOptions.input = val;
},
//根据设备搜索
inputVal(val) {
if (val?.length === 0) {
//poi查询
this.addMarker();
//显示安徽省
this.drawBounds("安徽省");
return;
}
var position = val
this.icon.size = [12, 18]
this.map.setCenter(position)
this.queryPoI()
this.map.setZoom(12, true, 1);
},
//修改主题
changeTheme(val) {
const styleName = "amap://styles/" + val;
this.map.setMapStyle(styleName);
},
//区域搜索
selectVal(val) {
if (val && val.length > 0) {
let vals = val[val?.length - 1];
vals = vals.replace(/\s+/g, '');
this.queryPoI()
this.placeSearch.search(vals);
this.drawBounds(vals);
this.map.setZoom(15, true, 1);
}
},
//添加marker
addMarker() {
const icon = this.icon
let layer = new this.AMap.LabelsLayer({
zooms: [3, 20],
zIndex: 1000,
collision: false,
});
// 将图层添加到地图
this.map.add(layer);
// 普通点
let markers = [];
this.positions.forEach((item) => {
const content = `
<div class="custom-info-window">
<div class="info-window-header"><b>${item.cityName}</b></div>
<div class="info-window-body">
<div>边设备数 : ${item.edgNum} 台</div>
<div>端设备数 : ${item.endNum} 台</div>
</div>
</div>
`;
let labelMarker = new AMap.LabelMarker({
position: item.dot,
icon: icon,
rank: 1, //避让优先级
});
const infoWindow = new AMap.InfoWindow({
content: content, //传入字符串拼接的 DOM 元素
anchor: "top-left",
});
labelMarker.on('mouseover', () => {
infoWindow.open(this.map, item.dot);
});
labelMarker.on('mouseout', () => {
infoWindow.close();
});
labelMarker.on('click', () => {
this.map.setCenter(item.dot)
this.queryPoI()
this.map.setZoom(15, true, 1);
})
markers.push(labelMarker);
});
// 一次性将海量点添加到图层
layer.add(markers);
},
//POI查询
queryPoI() {
this.auto = new this.AMap.AutoComplete(this.autoOptions);
this.placeSearch = new this.AMap.PlaceSearch({
map: this.map,
}); //构造地点查询类
this.auto.on("select", this.select);
this.addMarker();
},
//选择数据
select(e) {
this.placeSearch.setCity(e.poi.adcode);
this.placeSearch.search(e.poi.name); //关键字查询查询
this.map.setZoom(15, true, 1);
},
// 行政区边界绘制
drawBounds(newValue) {
//加载行政区划插件
if (!this.district) {
this.map.plugin(["AMap.DistrictSearch"], () => {
this.district = new AMap.DistrictSearch(this.districtSearchOpt);
});
}
//行政区查询
this.district.search(newValue, (_status, result) => {
if (Object.keys(result).length === 0) {
this.$message.warning("未查询到该地区数据");
return
}
if (this.polygons != null) {
this.map.remove(this.polygons); //清除上次结果
this.polygons = [];
}
//绘制行政区划
result?.districtList[0]?.boundaries?.length > 0 &&
result.districtList[0].boundaries.forEach((item) => {
let polygon = new AMap.Polygon({
strokeWeight: 1,
path: item,
fillOpacity: 0.1,
fillColor: "#22886f",
strokeColor: "#22886f",
});
this.polygons.push(polygon);
});
this.map.add(this.polygons);
this.map.setFitView(this.polygons); //视口自适应
});
},
},
};
</script>
<style lang="less" scoped>
.map {
width: 100%;
height: 100%;
position: relative;
.map_container {
width: 100%;
height: 100%;
}
.serach {
position: absolute;
z-index: 33;
left: 20px;
top: 10px;
}
}
</style>
<style>
//去除高德的logo
.amap-logo {
right: 0 !important;
left: auto !important;
display: none !important;
}
.amap-copyright {
right: 70px !important;
left: auto !important;
opacity: 0 !important;
}
/* 自定义 infoWindow 样式 */
.custom-info-window {
font-family: Arial, sans-serif;
padding: 10px;
border-radius: 8px;
background-color: #ffffff;
max-width: 250px;
}
</style>
在子组件中构建查询
<template>
<div class="box">
<div class="input_area">
<el-input placeholder="请输入设备名称" :id="search_id" v-model="input" size="mini" class="input_item" />
<img src="../../assets/input.png" alt="" class="img_logo" />
<span class="el-icon-search search" @click="searchMap"></span>
</div>
<div class="select_area">
<el-cascader :options="options" size="mini" placeholder="选择地域查询" :show-all-levels="false" :props="cityProps"
clearable v-model="cityVal" @change="selectCity"></el-cascader>
</div>
<div class="date_area">
<el-select v-model="themeValue" placeholder="请选择地图主题" size="mini" @change="changeTheme">
<el-option v-for="item in themeOptions" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
</div>
</div>
</template>
<script>
import cityRegionData from "../../assets/area"
import cityJson from "../../assets/city.json";
export default {
name: "search",
components: {},
mixins: [],
props: {},
data() {
return {
search_id: "searchId",
input: "",
options: cityRegionData,
cityProps: {
children: "children",
label: "business_name",
value: "business_name",
checkStrictly: true
},
cityVal: "",
themeOptions: [
{ label: "标准", value: "normal" },
{ label: "幻影黑", value: "dark" },
{ label: "月光银", value: "light" },
{ label: "远山黛", value: "whitesmoke" },
{ label: "草色青", value: "fresh" },
{ label: "雅士灰", value: "grey" },
{ label: "涂鸦", value: "graffiti" },
{ label: "马卡龙", value: "macaron" },
{ label: "靛青蓝", value: "blue" },
{ label: "极夜蓝", value: "darkblue" },
{ label: "酱籽", value: "wine" },
],
themeValue: ""
};
},
computed: {},
watch: {},
mounted() {
this.sendId();
},
methods: {
sendId() {
this.$emit("share_id", this.search_id);
},
searchMap() {
console.log(this.input,'ssss');
if (!this.input) {
this.$emit("input_val", []);
return
}
let val = cityJson.find(item => item.equipName === this.input)
if (val) {
this.$emit("input_val", val.dot);
return
}
this.$message.warning("未查询到该设备,请输入正确的设备名称");
},
selectCity() {
this.$emit("select_val", this.cityVal);
},
changeTheme(val) {
this.$emit("change_theme", val);
}
},
};
</script>
<style lang="less" scoped>
.box {
display: flex;
.input_area {
position: relative;
width: 170px;
height: 50px;
display: flex;
align-items: center;
.input_item {
width: 100%;
/deep/ .el-input__inner {
padding-left: 30px !important;
}
}
.img_logo {
position: absolute;
left: 5px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
margin-right: 10px;
}
span {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: #ccc;
cursor: pointer;
}
}
.select_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
.date_area {
width: 150px;
display: flex;
align-items: center;
height: 50px;
margin-left: 10px;
}
}
</style>
效果如下
来源:juejin.cn/post/7386650134744596532
用Three.js搞个炫酷雷达扩散和扫描特效
1.画点建筑模型
添加光照,开启阴影
//开启renderer阴影
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
//设置环境光
const light = new THREE.AmbientLight(0xffffff, 0.6); // soft white light
this.scene.add(light);
//夜晚天空蓝色,假设成蓝色的平行光
const dirLight = new THREE.DirectionalLight(0x0000ff, 3);
dirLight.position.set(50, 50, 50);
this.scene.add(dirLight);
平行光设置阴影
//开启阴影
dirLight.castShadow = true;
//阴影相机范围
dirLight.shadow.camera.top = 100;
dirLight.shadow.camera.bottom = -100;
dirLight.shadow.camera.left = -100;
dirLight.shadow.camera.right = 100;
//阴影影相机远近
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 200;
//阴影贴图大小
dirLight.shadow.mapSize.set(1024, 1024);
- 平行光的阴影相机跟正交相机一样,因为平行光的光线是平行的,就跟视线是平行一样,切割出合适的阴影视角范围,用于计算阴影。
- shadow.mapSize设置阴影贴图的宽度和高度,值越高,阴影的质量越好,但要花费计算时间更多。
增加建筑
//添加一个平面
const pg = new THREE.PlaneGeometry(100, 100);
//一定要用受光材质才有阴影效果
const pm = new THREE.MeshStandardMaterial({
color: new THREE.Color('gray'),
transparent: true,//开启透明
side: THREE.FrontSide//只有渲染前面
});
const plane = new THREE.Mesh(pg, pm);
plane.rotateX(-Math.PI * 0.5);
plane.receiveShadow = true;//平面接收阴影
this.scene.add(plane);
//随机生成建筑
this.geometries = [];
const helper = new THREE.Object3D();
for (let i = 0; i < 100; i++) {
const h = Math.round(Math.random() * 15) + 5;
const x = Math.round(Math.random() * 50);
const y = Math.round(Math.random() * 50);
helper.position.set((x % 2 ? -1 : 1) * x, h * 0.5, (y % 2 ? -1 : 1) * y);
const geometry = new THREE.BoxGeometry(5, h, 5);
helper.updateWorldMatrix(true, false);
geometry.applyMatrix4(helper.matrixWorld);
this.geometries.push(geometry);
}
//长方体合成一个形状
const mergedGeometry = BufferGeometryUtils.mergeGeometries(this.geometries, false);
//建筑贴图
const texture = new THREE.TextureLoader().load('assets/image.jpg');
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
const material = new THREE.MeshStandardMaterial({ map: texture,transparent: true });
const cube = new THREE.Mesh(mergedGeometry, material);
//形状产生阴影
cube.castShadow = true;
//形状接收阴影
cube.receiveShadow = true;
this.scene.add(cube);
效果就是很多高楼大厦的样子,为什么楼顶有窗?别在意这些细节,有的人就喜欢开天窗呢~
2.搞个雷达扩散和扫描特效
改变建筑材质shader,计算建筑的俯视uv
material.onBeforeCompile = (shader, render) => {
this.shaders.push(shader);
//范围大小
shader.uniforms.uSize = { value: 50 };
shader.uniforms.uTime = { value: 0 };
//修改顶点着色器
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
` uniform float uSize;
varying vec2 vUv;
void main() {`
);
shader.vertexShader = shader.vertexShader.replace(
'#include <fog_vertex>',
`#include <fog_vertex>
//计算相对于原点的俯视uv
vUv=position.xz/uSize;`
);
//修改片元着色器
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`varying vec2 vUv;
uniform float uTime;
void main() {`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`#include <dithering_fragment>
//渐变颜色叠加
gl_FragColor.rgb=gl_FragColor.rgb+mix(vec3(0,0.5,0.5),vec3(1,1,0),vUv.y);`
);
};
然后你将同样的onBeforeCompile函数赋值给平面的时候,没有对应的效果。
因为平面没有z,只有xy,而且经过了-90度旋转后,坐标位置也要对应反转,由此可以得出平面的uv计算公式
vUv=vec2(position.x,-position.y)/uSize;
至此,建筑和平面的俯视uv一致了。
雷达扩散特效
- 雷达扩散就是一段渐变的环,随着时间扩大。
- 顶点着色器不变,改一下片元着色器,增加扩散环颜色uColor,对应shader.uniforms也要添加
shader.uniforms.uColor = { value: new THREE.Color('#00FFFF') };
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
//计算与中心的距离
float d=length(vUv);
if(d >= uTime&&d<=uTime+ 0.1) {
//扩散圈
gl_FragColor.rgb = gl_FragColor.rgb+mix(uColor,gl_FragColor.rgb,1.0-(d-uTime)*10.0 )*0.5 ;
}`;
shader.fragmentShader = shader.fragmentShader.replace('void main() {', fragmentShader1);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
fragmentShader2);
//改变shader的时间变量,动起来
animateAction() {
if (this.shaders?.length) {
this.shaders.forEach((shader) => {
shader.uniforms.uTime.value += 0.005;
if (shader.uniforms.uTime.value >= 1) {
shader.uniforms.uTime.value = 0;
}
});
}
}
噔噔噔噔,完成啦!是立体化的雷达扩散,看起来很酷的样子。
雷达扫描特效
跟上面雷达扩散差不多,只要修改一下片元着色器
- 雷达扫描是通过扇形渐变形成的,还要随着时间旋转角度,shaderToy参考链接
const fragmentShader1 = `varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSize;
//旋转角度矩阵
mat2 rotate2d(float angle)
{
return mat2(cos(angle), - sin(angle),
sin(angle), cos(angle));
}
//雷达扫描渐变扇形
float vertical_line(in vec2 uv)
{
if (uv.y > 0.0 && length(uv) < 1.2)
{
float theta = mod(180.0 * atan(uv.y, uv.x)/3.14, 360.0);
float gradient = clamp(1.0-theta/90.0,0.0,1.0);
return 0.5 * gradient;
}
return 0.0;
}
void main() {`;
const fragmentShader2 = `#include <dithering_fragment>
mat2 rotation_matrix = rotate2d(- uTime*PI*2.0);
//将雷达扫描扇形渐变混合到颜色中
gl_FragColor.rgb= mix( gl_FragColor.rgb, uColor, vertical_line(rotation_matrix * vUv)); `;
GitHub地址
来源:juejin.cn/post/7349837128508964873
粒子特效particles.js
效果图
版本:"particles.js": "^2.0.0"
npm i particles.js
Vue2版本
组件代码:src/App.vue
<template>
<div class="particles-js-box">
<div id="particles-js"></div>
</div>
</template>
<script>
import particlesJs from "particles.js";
import particlesConfig from "./assets/particles.json";
export default {
data() {
return {};
},
mounted() {
this.init();
},
methods: {
init() {
particlesJS("particles-js", particlesConfig);
document.body.style.overflow = "hidden";
},
},
};
</script>
<style scoped>
.particles-js-box {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: -1;
}
#particles-js {
background-color: #18688d;
width: 100%;
height: 100%;
}
</style>
代码里的json数据:
目录:src/assets/particles.json
{
"particles": {
"number": {
"value": 60,
"density": {
"enable": true,
"value_area": 800
}
},
"color": {
"value": "#ddd"
},
"shape": {
"type": "circle",
"stroke": {
"width": 0,
"color": "#000000"
},
"polygon": {
"nb_sides": 5
},
"image": {
"src": "img/github.svg",
"width": 100,
"height": 100
}
},
"opacity": {
"value": 0.5,
"random": false,
"anim": {
"enable": false,
"speed": 1,
"opacity_min": 0.1,
"sync": false
}
},
"size": {
"value": 3,
"random": true,
"anim": {
"enable": false,
"speed": 40,
"size_min": 0.1,
"sync": false
}
},
"line_linked": {
"enable": true,
"distance": 150,
"color": "#ffffff",
"opacity": 0.4,
"width": 1
},
"move": {
"enable": true,
"speed": 4,
"direction": "none",
"random": false,
"straight": false,
"out_mode": "out",
"bounce": false,
"attract": {
"enable": false,
"rotateX": 100,
"rotateY": 1200
}
}
},
"interactivity": {
"detect_on": "Window",
"events": {
"onhover": {
"enable": true,
"mode": "grab"
},
"onclick": {
"enable": true,
"mode": "push"
},
"resize": true
},
"modes": {
"grab": {
"distance": 140,
"line_linked": {
"opacity": 1
}
},
"bubble": {
"distance": 400,
"size": 40,
"duration": 2,
"opacity": 8,
"speed": 3
},
"repulse": {
"distance": 200,
"duration": 0.4
},
"push": {
"particles_nb": 4
},
"remove": {
"particles_nb": 2
}
}
},
"retina_detect": true
}
Vue3版本
{
"name": "vue3-test",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"particles.js": "^2.0.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"prettier": "^3.3.3",
"sass-embedded": "^1.83.0",
"vite": "^6.0.1",
"vite-plugin-vue-devtools": "^7.6.5"
}
}
需要修改 /node_modules/particles.js/particles.js 的代码
修改此 2 处,
我是把它拷贝到 src 目录下
组件使用:跟 vue2 一样,就是上面第8行引入不一样
import particlesJs from "@/particles.js";
来源:juejin.cn/post/7452931747785883684
为什么Rust 是 web 开发的理想选择
为什么Rust 是 web 开发的理想选择
Rust 经常被视为仅仅是一种系统编程语言,但实际上它是一种多用途的通用语言。像 Tauri(用于桌面应用)、Leptos(用于前端开发)和 Axum(用于后端开发)这样的项目表明 Rust 的用途远不止于系统编程。
当我开始学习 Rust 时,我构建了一个网页应用来练习。因为我主要是一名后端工程师,这是我最为熟悉的领域。很快我就意识到 Rust 非常适合做网页开发。它的特性让我有信心构建出可靠的应用程序。让我来解释一下为什么 Rust 是网页编程的理想选择。
错误处理
一段时间以前,我开始学习 Python,那时我被机器学习的热潮所吸引,甚至是在大型语言模型(LLM)热潮之前。我需要让机器学习模型可以被使用,因此我选择了编写一个 Django REST API。在 Django 中获取请求体,你可能会这样写代码:
class User(APIView):
def post(self, request):
body = request.data
这段代码大多数时候都能正常工作。然而,当我意外地发送了一个格式不正确的请求体时,它就不再起作用了。访问数据时抛出了异常,导致返回了 500 状态码的响应。我没有意识到这种访问可能会抛出异常,并且也没有明确的提示。
Rust 通过不抛出异常而是以 Result 形式返回错误作为值来处理这种情况。Result 同时包含值和错误,你必须处理错误才能访问该值。
let body: RequestBody = serde_json::from_slice(&requestData)?;
问号 (?) 表示你想在调用函数中处理错误,将错误向上一级传播。
我认为任何将错误作为值来处理的语言都是正确处理错误的方式。这种方法允许你编写代码时避免出现意外的情况,就像 Python 示例中的那样。
默认不可变性
最近,我的一位同事在我们的一个开源项目上工作,他需要替换一个客户端库为另一个。这是他使用的代码:
newClient(
WithHTTPClient(httpClient), // &http.Client{}
WithEndpoint(config.ApiBasePath),
)
突然间,集成测试开始抛出竞态条件错误,他搞不清楚为什么会这样。他向我求助,我们一起追踪问题回到了这行代码。我们在其他客户端之间共享了这个HTTP客户端,这导致了错误的发生。多个协程在读取客户端,而 WithHttpClient 函数修改了客户端的状态。在同一资源上同时有读线程和写线程会导致未定义的行为或在 Go 语言中引发恐慌。
这又是一个令人不悦的意外。而在 Rust 中,所有变量默认是不可变的。如果你想修改一个变量,你需要显式地声明它,使用 mut 关键字。这有助于 API 客户端理解发生了什么,并避免了意外的修改。
fn with_httpclient(client: &mut reqwest::Client) {}
宏
在像 Java 和 Python 这样的语言中,你们有注解;而在 Rust 中,我们使用宏。注解可以在某些环境下如 Spring 中带来优雅的代码,其中大量的幕后工作是通过反射完成的。虽然 Rust 的宏提供的“魔法”较少,但也同样能产生更清晰的代码。这里有一个 Rust 宏的例子:
sqlx::query_as!(Student, "DELETE FROM student WHERE id = ? RETURNING *", id)
Rust 中的宏会在后台生成代码,编译器在构建过程中会检查这些代码的正确性。通过宏,你甚至可以在编译时扩展编译器检查并验证 SQL 查询,方法是在编译期间生成运行查询的真实数据库上的代码。
这种能够在编译时检查代码正确性的能力开辟了新的可能性,特别是在 web 开发中,我们经常编写原始的数据库语句或 HTML 和 CSS 代码。它帮助我们写出更少 bug 的代码。
这里提到的宏被称为声明式宏。Rust 还有过程式宏,它们更类似于其他语言中的注解。
#[instrument(name = "UserRepository::begin")]
pub async fn begin(&self) {}
核心思想保持不变:在后台生成代码,并在方法调用前后执行一些逻辑,从而确保代码更加健壮和易于维护。
Chaining
来看看这段在 Rust 中优雅的代码:
let key_value = request.int0_inner()
.key_value
.ok_or_else(|| ServerError::InvalidArgument("key_value must be set".to_string()))?;
与这种更为冗长的方法相比:
Optional<KeyValue> keyValueOpt = request.getInner().getKeyValue();
if (!keyValueOpt.isPresent()) {
throw new IllegalArgumentException("key_value must be set");
}
KeyValue keyValue = keyValueOpt.get();
在 Rust 中,我们可以将操作链接在一起,从而得到简洁且易读的代码。但是,为了实现这种流畅的语法,我们通常需要实现诸如 From 这样的特质。
功能性技术大佬们可能会认识并欣赏这种方法,他们有这样的见解是有道理的。我认为任何允许混合函数式和过程式编程的语言都是走在正确的道路上。它为开发者提供了灵活性,让他们可以选择最适合其特定应用场景的方式。
线程安全
这里有没有人曾经因为竞态条件而在生产环境中触发过程序崩溃?我羞愧地承认,我有过这样的经历。是的,这是一个技能问题。当你在启动多个线程的同时对同一内存地址进行修改和读取时,很难不去注意到这个问题。但让我们考虑这样一个例子:
type repo struct {
m map[int]int
}
func (r *repo) Create(i int) {
r.m[i] = i
}
type Server struct {
repo *repo
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
s.repo.Create(1)
}
没有显式启动任何线程,乍一看,一切似乎都很好。然而实际上,HTTP 服务器是在多个线程上运行的,这些线程被抽象隐藏了起来。在 web 开发中,这种抽象可能会掩盖与多线程相关的潜在问题。现在,让我们用 Rust 实现相同的功能:
struct repo {
m: std::collections::HashMap<i8, i8>
}
#[post("/maps")]
async fn crate_entry(r: web::Data<repo>) -> HttpResponse {
r.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
当我们尝试编译这个程序时,Rust 编译器将会抛出一个错误:
error[E0596]: cannot
borrow data in an
`Arc` as mutable
--> src\main.rs:117:5
|
117 | r.m.insert(1, 2);
| ^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<repo>`
很多人说 Rust 的错误信息通常是很有帮助的,这通常是正确的。然而,在这种情况下,错误信息可能会让人感到困惑并且不是立即就能明白。幸运的是,如果知道如何解决,修复方法很简单:只需要添加一个小的互斥锁:
struct repo {
m: HashMap<i8, i8>
}
#[post("/maps")]
async fn create_entry(r: web::Data<Mutex<repo>>) -> HttpResponse {
let mut map = r.lock().await();
map.m.insert(1, 2);
HttpResponse::Ok().json(MessageResponse {
message: "good".to_string(),
})
}
确实非常美妙,编译器能够帮助我们避免这些问题,让我们的代码保持安全和可靠。
空指针解引用
大多数人认为这个问题只存在于 C 语言中,但你也会在像 Java 或 Go 这样的语言中遇到它。这里是一个典型问题的例子:
type valueObject struct {
value *int
}
func getValue(vo *valueObject) int {
return *vo.value
}
你可能会说,“在使用值之前检查它是否为 nil 就好了。”这是 Go 语言中最大的陷阱之一 —— 它的指针机制。有时候我们会优化内存分配,有时候我们使用指针来表示可选值。
空指针解引用的风险在处理接口时尤其明显。
type Repository interface {
Get() int
}
func getValue(r Repository) int {
return r.Get()
}
func main() {
getValue(nil)
}
在许多语言中,将空值作为接口的有效选项传递是可以的。虽然代码审查通常会发现这类问题,但我还是见过一些空接口进入开发阶段的情况。在 Rust 中,这类问题是不可能发生的,这是对我们错误的另一层保护:
trait Repository {
fn get(&self) -> i32;
}
fn get_value(r: impl Repository) -> i32 {
r.get()
}
fn main() {
get_value(std::ptr::null());
}
Not to mention that it does not compile.
更不用说这段代码根本无法编译。
我承认,我是端口和适配器模式的大粉丝,这些模式包括了一些抽象概念。根据复杂度的不同,这些抽象可能是必要的,以便在你的应用程序中创建清晰的边界,从长远来看提高单元测试性和可维护性。批评者的一个论点是性能会下降,因为通常需要动态调度,因为在编译时无法确定具体的接口实现。让我们来看一个 Java 的例子:
@Service
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
@Autowired
public StudentServiceImpl(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
}
Spring 为我们处理了很多幕后的事务。其中一个特性就是使用 @Autowired 注解来进行依赖注入。当应用程序启动时,Spring 会进行类路径扫描和反射。然而,这种便利性却伴随着性能成本。
在 Rust 中,我们可以创建这些清晰的抽象而不付出性能代价,这得益于所谓的零成本抽象:
struct ServiceImpl<T: Repository> {
repo: T,
}
trait Service{}
fn new_service<T: Repository>(repo: T) -> impl Service {
ServiceImpl { repo: repo }
}
这些抽象在编译时就被处理好,确保在运行时不会有任何性能开销。这使我们能够在不牺牲性能的情况下保持代码的整洁和高效。
数据转换
在企业级应用中,我们经常使用端口和适配器模式来处理复杂的业务需求。这种模式涉及将数据转换成不同层次所需的表示形式。我们可能通过异步通信接收到用户数据作为事件,或者通过同步通信接收到用户数据作为请求。然后,这些数据被转换成领域模型并通过服务和适配器层进行传递。
这就提出了一个问题:数据转换的逻辑应该放在哪里?应该放在领域包中吗?还是放在数据映射所在的包中?我们应该如何调用方法来转换数据?这些问题常常导致整个代码库中出现不一致性。
Rust 在提供一种清晰的方式来处理数据转换方面表现出色,使用 From 特质。如果我们需要将数据从适配器转换到领域,我们只需在适配器中实现 From 特质:
impl From<UserRequest> for domain::DomainUser {
fn from(user: UserRequest) -> Self {
domain::DomainUser {}
}
}
impl From<domain::DomainUser> for UserResponse {
fn from(user: domain::DomainUser) -> Self {
UserResponse {}
}
}
fn create_user(user: UserRequest) -> Result<()> {
let domain_user = domain::upsert_user(user.int0());
send_response(domain_user.int0())?;
Ok(())
}
通过在需要的地方实现 From 特质,Rust 提供了一种一致且直接的方式来处理数据转换,减少了不一致性,并使代码库更加易于维护。
性能
当然,Rust 很快这一点毋庸置疑,但它实际上给我们带来了哪些好处呢?我记得第一次将我的 Django 应用部署到 Kubernetes 上,并使用 kubectl top pods 命令来检查 CPU 和内存使用情况的时候。我很震惊地发现,即使没有任何负载,这个应用也几乎占用了 1GB 的 RAM。Java 也没好到哪里去。后来我发现了像 Rust 和 Go 这样的新语言,意识到事情可以做得更高效。
我查找了一些性能和资源使用方面的基准测试,并发现使用能够高效利用资源的语言可以节省很多成本。这里有一个例子:
Link to the original article
Link to the original article
想象一下,有一个 Lambda 函数被创建用来列出 AWS 账户中的所有存储桶,并确定每个存储桶所在的区域。你可能会认为,进行一些 REST API 调用并使用 for 循环在性能上不会有太大的区别。任何语言都应该能够合理地处理这个任务,对吧?
然而,测试显示 Rust 在执行这项任务时比 Python 快得多,并且使用更少的内存来达到这些执行时间。事实上,他们每百万次调用节省了 6 美元。
来自 web 和 Kubernetes 的背景,在那里我们根据用户负载进行扩缩容,我可以确认高效的资源使用能够节省成本并提高系统的可靠性。每个副本使用较少的资源意味着更多的容器可以装入一个节点。如果每个副本能够处理更多的请求,则总体上需要的副本数量就会减少。高性能和高效的资源利用对于构建成本效益高且可靠的系统至关重要。
我已经在 web 开发领域使用 Rust 三年了,对此我非常满意。那些具有挑战性的方面,比如编写异步代码或宏,都被我们使用的库很好地处理了。例如,如果你研究过 Tokio 库,你会知道它可能相当复杂。但在 web 开发中,我们专注于业务逻辑并与外部系统(如数据库或消息队列)交互,我们得以享受更简单的一面,同时受益于 Rust 出色的安全特性。
试试 Rust 吧;你可能会喜欢它,甚至成为一名更好的程序员。
来源:juejin.cn/post/7399288740908531712
我:CSS,你怎么变成紫色了?CSS:别管这些,都快2025了,这些新特性你还不知道吗?🤡
事情起因是这样的,大二的苦逼学生在给老外做页面的时候,做着做着无意间瞥见了css的图标。
wait!你不是我认识的css!你是谁?
我的天呐,你怎么成了这种紫色方块?(如果只关心为什么图标换了,可以直接跳到文章末尾)
这提起了我的兴趣,立马放下手中的工作去了解。查才知道,这是有原因的,而且在这之间CSS也更新了很多新特性。
不过看了许多博客,发现没啥人说这件事(也可能是我没找到),所以到我来更新了!😄
这里主要谈谈我认为还算有点用的新特性,全文不长,如果对您有用的话,麻烦给个赞和收藏加关注呗🙏!如果可以的话,掘金人气作者评选活动给孩子投一票吧😭
先叠个甲:所有观点纯本菜🐔意淫,各位大佬地方看个乐呵就行。
参考文献:张鑫旭的个人主页 » 张鑫旭-鑫空间-鑫生活
和MDN Web Docs
块级元素居中新方式:Align Content for Block Elements
元素垂直居中对齐终于有了专门的CSS属性,之前Flex布局和Grid布局中使用的align-content
属性,现在已经可以在普通的block块级元素中使用。
垂直居中的快级元素不再需要 flex 或 grid,注意是在垂直居中!!!
display: block; <-非块级元素请加上这个代码
align-content: center;
不过我好像以前用过,不过用的很少,不知道是不是发生了改变造成了这种效果🤡
请看如下代码
<style>
.father {
display: block;
align-content: center;
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
可以发现是div是垂直居中显示的
实现效果和用flex是一样的
<style>
.father {
display: flex;
align-item:center
background-color: aqua;
width: 300px;
height: 300px
}
.son {
width: 100px;
height: 100px;
background-color: red;
}
</style>
提醒一下,目前普通元素并不支持justify-content属性,必须Flex布局或者Grid布局。
subgrid
额,这个特性似乎国内没有很多文章讲解,但是我记得之前看过一个统计这个特性在老外那里很受欢迎,所以我还是讲解一下。
subgrid并不是一个CSS属性,而是 grid-template-columns
和grid-template-rows
属性支持的关键字,其使用的场景需要外面已经有个Grid布局,否则……嗯,虽然语法上不会识别为异常,但是渲染结果上却是没有区别的。
例如
.container {
display: grid;
}
.item {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: subgrid;
}
那我们什么时候使用它呢?🤔
当我们想实现这种效果
Grid布局负责大的组织结构,而里面更细致的排版对齐效果,则可以使用subgrid布局。,这对于复杂的嵌套布局特别有用,在 subgrid 出现之前,嵌套网格往往会导致 CSS 变得复杂冗长。(其实你用flex也可以)
子网格允许子元素与父网格无缝对齐,从而简化了这一过程。
.container {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.item {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
gap: .5rem;
}
/* 以下CSS与布局无关 */
.item {
padding: .75rem;
background: #f0f3f9;
}
.item blockquote {
background-color: skyblue;
}
.item h4 {
background-color: #333;
color: #fff;
}
<div class="container">
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
<section class="item">
<h4>1</h4>
<p>负责人:张三</p>
<blockquote>脑子和我只能活一个</blockquote>
<footer>3人参与 12月21日</footer>
</section>
</div>
效果
@property
@property规则属于CSS Houdini中的一个特性,可以自定义CSS属性的类型,这个特性在现代CSS开发中还是很有用的,最具代表性的例子就是可以让CSS变量支持动画或过渡效果。
我个人认为这个东西最大的作用就是在我们写颜色渐变的时候很好避免使用var()
不小心造成颜色继承的,而导致效果不理想。
用法
@property --rotation {
syntax: "<angle>";
inherits: false;
initial-value: 45deg;
}
描述符
syntax
描述已注册自定义属性允许的值类型的字符串。可以是数据类型名称(例如<color>
、<length>
或<number>
等),带有乘数(+
、#
)和组合符(|
),或自定义标识。inherits
一个布尔值,控制指定的自定义属性注册是否默认@property
继承。initial-value
设置属性的起始值的值。
描述
注意
简单演示
@property --box-pink {
syntax: "<color>";
inherits: false;
initial-value: pink;
}
.box {
width: 100px;
height: 100px;
background-color: var(--box-pink);
}
使用它进行颜色渐变
@property --colorA {
syntax: "<color>";
inherits: false;
initial-value: red;
}
@property --colorB {
syntax: "<color>";
inherits: false;
initial-value: yellow;
}
@property --colorC {
syntax: "<color>";
inherits: false;
initial-value: blue;
}
.box {
width: 300px;
height: 300px;
background: linear-gradient(45deg,
var(--colorA),
var(--colorB),
var(--colorC));
animation: animate 3s linear infinite alternate;
}
@keyframes animate {
20% {
--colorA: blue;
--colorB: #F57F17;
--colorC: red;
}
40% {
--colorA: #FF1744;
--colorB: #5E35B1;
--colorC: yellow;
}
60% {
--colorA: #E53935;
--colorB: #1E88E5;
--colorC: #4CAF50;
}
80% {
--colorA: #76FF03;
--colorB: teal;
--colorC: indigo;
}
}
</style>
transition-behavior让display none也有动画效果
大家都知道我们在设置一个元素隐藏和出现是一瞬间的,那有没有办法让他能出现类似于淡入淡出的动画效果呢?
这里我们就要介绍transition-behavior了,但是也有其他方法,这里就只介绍它。
语法如下:
transition-behavior: allow-discrete;
transition-behavior: normal;
allow-discrete
表示允许离散的CSS属性也支持transition
过渡效果,其中,最具代表性的离散CSS属性莫过于display属性了。
使用案例
仅使用transition
属性,实现元素从 display:inline ↔ none 的过渡效果。
img {
transition: .25s allow-discrete;
opacity: 1;
height: 200px;
}
img[hidden] {
opacity: 0;
}
<button id="trigger">图片显示与隐藏</button>
<img id="target" src="./1.jpg" />
trigger.onclick = function () {
target.toggleAttribute('hidden');
};
这里我们可以发现消失的时候是有淡出效果的,但是出现却是一瞬间的,这是为什么?
原因是:
display:none
到display:block
的显示是突然的,在浏览器的渲染绘制层面,元素display计算值变成block和opacity设为1是在同一个渲染帧完成的,由于没有起始opacity,所以看不到动画效果。
那有没有什么办法能解决呢?🤔
使用@starting-style规则声明过渡起点
@starting-style
顾名思义就是声明起始样式,专门用在transition过渡效果中。
例如上面的例子,要想让元素display显示的时候有淡出效果,很简单,再加三行代码就可以了:
img {
transition: .25s allow-discrete;
opacity: 1;
@starting-style {
opacity: 0;
}
}
或者不使用CSS嵌套语法,这样写也是可以的:
img {
transition: .25s allow-discrete;
opacity: 1;
}
@starting-style {
img {
opacity: 0;
}
}
此时,我们再点击按钮让图片显示,淡入淡出效果就都有了。
注意:
@starting-style
仅与 CSS 过渡相关。使用CSS 动画实现此类效果时,@starting-style
就不需要。
light-dark
先说明一下,我认为 CSS 的新 light-dark() 函数是 2024 年实现暗模式的最佳方式!
你自 2019 年以来,开发人员只需一行 CSS 就可以为整个站点添加暗模式?只需在 :root 中添加 color-scheme: light dark;,就可以获得全站暗模式支持——但它只适用于未指定颜色的元素,因此使用默认的浏览器颜色。
如果你想让自定义颜色的暗模式生效(大多数网站都需要),你需要将每个颜色声明包装在笨拙的 @media (prefers-color-scheme: ...) 块中:
@media (prefers-color-scheme: dark) {
body {
color: #fff;
background-color: #222;
}
}
@media (prefers-color-scheme: light) {
body {
color: #333;
background-color: #fff;
}
}
基本上,你需要把每个颜色声明写两遍。糟糕!这种冗长的语法使得编写和维护都很麻烦。因此,尽管 color-scheme
已发布五年,但从未真正流行起来。
light-dark很好解决了这个问题
基本语法
/* Named color values */
color: light-dark(black, white);
/* RGB color values */
color: light-dark(rgb(0 0 0), rgb(255 255 255));
/* Custom properties */
color: light-dark(var(--light), var(--dark));
body {
color-scheme: light dark; /* 启用浅色模式和深色模式 */
color: light-dark(#333, #fff); /* 文本浅色和深色颜色 */
background-color: light-dark(#fff, #222); /* 背景浅色和深色颜色 */
}
在这个示例代码中,正文文本在浅色模式下定义为 #333
,在深色模式下定义为 #fff
,而背景色则分别定义为 #fff
和 #222
。就这样!浏览器会根据用户的系统设置自动选择使用哪种颜色。
无需 JavaScript 逻辑、自定义类或媒体查询。一切都能正常工作!
:user-vaild pseudo class
:user-valid
CSS伪类表示任何经过验证的表单元素,其值根据其验证约束正确验证。然而,与:valid
此不同的是,它仅在用户与其交互后才匹配。
有什么用呢?🤔
这就很好避免了我们在进行表单验证的时候,信息提示在你交互之前出现的尴尬。
<form>
<label for="email">Email *: </label>
<input
id="email"
name="email"
type="email"
value="test@example.com"
required />
<span></span>
</form>
input:user-valid {
border: 2px solid green;
}
input:user-valid + span::before {
content: "😄";
}
在以下示例中,绿色边框和😄仅在用户与字段交互后显示。我们将电子邮件地址更改为另一个有效的电子邮件地址就可以看到了
interpolate-size
interpolate-size
和calc-size()
函数属性的设计初衷是一致的,就是可以让width、height等尺寸相关的属性即使值是auto,也能有transition过渡动画效果。
最具代表性的就是height:auto的过渡动画实现。
p {
height: 0;
transition: height .25s;
overflow: hidden;
}
.active+p {
height: auto;
height: calc-size(auto, size);
}
<button onClick="this.classList.toggle('active');">点击我</button>
<p>
<img src="./1.jpg" width="256" />
</p>
其实,要让height:auto
支持transition过渡动画,还有个更简单的方法,就是在祖先容器上设置:
interpolate-size: allow-keywords;
换句话说,calc-size()
函数是专门单独设置,而interpolate-size
是全局批量设置。
interpolate-size: allow-keywords;
interpolate-size: numeric-only;
/* 全局设置 */
/* :root {
interpolate-size: allow-keywords;
} */
div {
width: 320px;
padding: 1em;
transition: width .25s;
/* 父级设置 */
interpolate-size: allow-keywords;
background: deepskyblue;
}
.active+div {
width: 500px;
}
</style>
<button onClick="this.classList.toggle('active');">点击我</button>
<div>
<img src="./1.jpg" width="256" />
</div>
全新的CSS相对颜色语法-使用from
from的作用我认为是简化了我们让文字自动适配背景色的步骤
我们先来看看用法
p {
color: rgb(from red r g b / alpha);
}
原因:r g b 以及 alpha 实际上是对red
的解构,其计算值分别是255 0 0 / 1(或者100%)。
注意:实际开发,我们不会使用上面这种用法,这里只是为了展示语法作用。
使用from让文字适配背景色
<button id="btn" class="btn">我是按钮</button>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
<p>请选择颜色:<input
type="color"
value="#2c87ff"
onInput="btn.style.setProperty('--color', this.value);"
></p>
rebecca purple(#663399)
好了,最重要的东西来了,关于为什么变成了紫色,其实他们把它叫做rebecca紫,为什么叫这个名字呢?这其实是一个令人悲伤的故事😭。
在关于css这个新颜色以及logo的时候,内部发生了许多争议。
但是相信大部分人都读过CSS The Definitive guide,他的作者Eric A.Myer的女儿在这期间因为癌症去世了
在她去世的前几周,Rebecca说她即将成为一个六岁大的女孩,而becca是一个婴儿的名字。六岁后,他希望每个人都叫他Rebecca,而不是becca。
而那个女孩和病魔抗争,一直坚持到她到六岁,
我无法想象假如我是父亲,失去一个那么可爱的一个六岁的孩子,那个心情有多么痛苦。
最终社区被他的故事感动了,css的logo也就变成了这样。
总结
新特性多到让人麻木,真的非常非常多!!!!😵💫
这些新特性出现的速度比某四字游戏出皮肤的速度还快🚀,关键这些特性浏览器支持情况参差不齐,短时间无法在生产环境应用。
我真的看了非常都非常久,从早上五点起来开始看文档,除去吃饭上课,加上去写文章一直弄到凌晨三点,才选出这么几个我认为还算有点作用的新特性。
而且现有的JavaScript能力已经足够应付目前所有的交互场景,很多新特性没有带来颠覆性的改变,缺少迫切性和必要性,很难被重视。
最后的最后,希望大家的身边的亲人身体都健健康康的,也希望饱受癌症折磨的人们能够早日康复🙏
来源:juejin.cn/post/7450434330672234530
Nuxt 3手写一个搜索页面
Nuxt 3手写一个搜索页面
前言
前面做了一个小型搜索引擎,虽然上线了,但总体来说还略显粗糙。所以最近花了点时间打磨了一下这个搜索引擎的前端部分,使用的技术是Nuxt
,UI组件库使用的是Vuetify
,关于UI组件库的选择,你也可以查看我之前写的这篇对比。
本文不会介绍搜索引擎的其余部分,算是一篇前端技术文...
重要的:开源地址,应用部分的代码我也稍微整理了一下开源了,整体来说偏简单,毕竟只有一个页面,算是真正的“单页面应用”了🤣🤣🤣
演示
为什么要重写
这次重写的目的如下:
- 之前写的代码太乱了,基本一个页面就只写了一个文件,维护起来有点困难;
- 之前的后端使用nest单独写的,其实就调调API,单独起一个后端服务感觉有点重;
- 最后一点也是最重要的:使用SSR来优化一下SEO
具体如下:
- 比如当用户输入搜索之后,对应的url路径也会发生变化,比如ssgo.app/?page=1&que…,
- 如果用户将该url分享到其他平台被搜索引擎抓取之后,搜索引擎得到的数据将不再是空白的搜索框,而是包含相关资源的结果页,
- 这样有可能再下一次用户在其他搜索引擎搜索对应资源的时候,有可能会直接跳转到该应用的搜索结果页,这样就可以大大提高该应用的曝光率。
这样,用户之后不仅可以通过搜索“阿里云盘搜索引擎”能搜到这个网站,还有可能通过其他资源的关键词搜索到该网站
页面布局
首先必须支持移动端,因为从后台的访问数据看,移动端的用户更多,所以整体布局以竖屏为主,至于宽屏PC,则增加一个类似于max-width
的效果。
其次为了整体实现简单,采取的还是搜索框与搜索结果处在一个页面,而非google\baidu之类的搜索框主页与结果页分别是两个页面,笔者感觉主页也没啥存在的必要(单纯对于搜索功能而言)
页面除了搜索框、列表项,还应该有logo,菜单,最终经过排版如下图所示:
左右两边为移动端的效果演示图,中间为PC端的效果演示。
nitro服务端部分
这里只需要实现两个API:
- 搜索接口,如
/api/search
- 搜索建议的接口,如
/api/search/suggest
说到这里就不得不夸一下nuxt的开发者体验,新建一个API是如此的方便:
对比nest-cli中新建一个service/controller要好用不少,毕竟我在nest-cli中基本要help
一下。
回到这里,我的server目录结构如下:
├─api
│ └─search # 搜索接口相关
│ index.ts # 搜索
│ suggest.ts # 搜索建议
│
└─elasticsearch
index.ts # es客户端
在elasticsearch
目录中,我创建了一个ES的客户端,并在search
中使用:
// elasticsearch/index.ts
import { Client } from '@elastic/elasticsearch';
export const client = new Client({
node: process.env.ES_URL,
auth: {
username: process.env.ES_AUTH_USERNAME || '',
password: process.env.ES_AUTH_PASSWORD || ''
}
});
然后使用,使用部分基本上没有做任何的特殊逻辑,就是调用ES client提供的api,然后组装了一下参数就OK了:
// api/search/index
import { client } from "~/server/elasticsearch";
interface ISearchQuery {
pageNo: number;
pageSize: number;
query: string;
}
export default defineEventHandler(async (event) => {
const { pageNo = 1, pageSize = 10, query }: ISearchQuery = getQuery(event);
const esRes = await client.search({
index: process.env.ES_INDEX,
body: {
from: (pageNo - 1) * pageSize, // 从哪里开始
size: pageSize, // 查询条数
query: {
match: {
title: query, // 搜索查询到的内容
},
},
highlight: {
pre_tags: ["<span class='highlight'>"],
post_tags: ['</span>'],
fields: {
title: {},
},
fragment_size: 40,
},
},
});
const finalRes = {
took: esRes.body.took,
total: esRes.body.hits.total.value,
data: esRes.body.hits?.hits.map((item: any) => ({
title: item._source.title,
pan_url: item._source.pan_url,
extract_code: item._source.extract_code,
highlight: item.highlight?.title?.[0] || '',
})),
};
return finalRes;
});
// api/search/suggest
import { client } from "~/server/elasticsearch";
interface ISuggestQuery {
input: string;
}
export default defineEventHandler(async (event) => {
const { input }: ISuggestQuery = getQuery(event);
const esRes = await client.search({
index: process.env.ES_INDEX,
body: {
suggest: {
suggest: {
prefix: input,
completion: {
field: "suggest"
}
}
}
},
});
const finalRes = esRes.body.suggest.suggest[0]?.options.map((item: any) => item._source.suggest)
return finalRes;
});
值得注意的是,客户端的ES版本需要与服务端的ES版本相互对应,比如我服务端使用的是ES7,这路也当然得使用ES7,如果你是ES8,这里需要安装对应版本得ES8,并且返回参数有些变化,ES8中上述esRes
就没有body属性,而是直接使用后面的属性
page界面部分
首先为了避免出现之前所有代码均写在一个文件中,这里稍微封装了几个组件以使得page/index
这个组件看起来相对简单:
/components
BaseEmpty.vue
DataList.vue
LoadingIndicator.vue
MainMenu.vue
PleaseInput.vue
RunSvg.vue
SearchBar.vue
具体啥意思就不赘述了,基本根据文件名就能猜得大差不差了...
然后下面就是我的主页面部分:
<template>
<div
class="d-flex justify-center bg-grey-lighten-5 overflow-hidden overflow-y-hidden"
>
<v-sheet
class="px-md-16 px-2 pt-4"
:elevation="2"
height="100vh"
:width="1024"
border
rounded
>
<v-data-iterator :items="curItems" :page="curPage" :items-per-page="10">
<template #header>
<div class="pb-4 d-flex justify-space-between">
<span
class="text-h4 font-italic font-weight-thin d-flex align-center"
>
<RunSvg style="height: 40px; width: 40px"></RunSvg>
<span>Search Search Go...</span>
</span>
<MainMenu></MainMenu>
</div>
<SearchBar
:input="curInput"
@search="search"
@clear="clear"
></SearchBar>
</template>
<template #default="{ items }">
<v-fade-transition>
<DataList
v-if="!pending"
:items="items"
:total="curTotal"
:page="curPage"
@page-change="pageChange"
></DataList>
<LoadingIndicator v-else></LoadingIndicator>
</v-fade-transition>
</template>
<template #no-data>
<template v-if="!curInput || !pending">
<v-slide-x-reverse-transition>
<BaseEmpty v-if="isInput"></BaseEmpty>
</v-slide-x-reverse-transition>
<v-slide-x-transition>
<PleaseInput v-if="!isInput"></PleaseInput>
</v-slide-x-transition>
</template>
</template>
</v-data-iterator>
</v-sheet>
</div>
</template>
<script lang="ts" setup>
const route = useRoute();
const { query = "", page = 1 } = route.query;
const router = useRouter();
const defaultData = { data: [], total: 0 };
const descriptionPrefix = query ? `正在搜索“ ${query} ”... ,这是` : "";
useSeoMeta({
ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
ogImage: "https://ssgo.app/logobg.png",
twitterCard: "summary",
});
interface IResultItem {
title: string;
pan_url: string;
extract_code: string;
highlight: string;
}
interface IResult {
data: IResultItem[];
total: number;
}
const curPage = ref(+(page || 1));
const curInput = ref((query || "") as string);
const isInput = computed(() => !!curInput.value);
let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
await useFetch("/api/search", {
query: { query: curInput, pageNo: curPage, pageSize: 10 },
immediate: !!query,
});
data.value = data.value || defaultData;
const curItems = computed(() => data.value.data);
const curTotal = computed(() => data.value.total);
function search(input: string) {
curPage.value = 1;
curInput.value = input;
router.replace({ query: { ...route.query, query: input, page: 1 } });
}
function pageChange(page: number) {
curPage.value = page;
router.replace({ query: { ...route.query, page: page } });
}
function clear() {
curInput.value = "";
data.value = defaultData;
// 这里就不替换参数了,保留上一次的感觉好一些
}
</script>
大部分代码都是调用相关的子组件,传递参数,监听事件之类的,这里也不多说了。比较关键的在于这两部分代码:
useSeoMeta({
ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
ogImage: "https://ssgo.app/logobg.png",
twitterCard: "summary",
});
这里的SEO显示的文字是动态的,比如当前用户正在搜索AI
,那么url路径参数也会增加AI
,分享出去的页面描述就会包含AI
,在twitter中的显示效果如下:
还有部分代码是这一部分:
let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
await useFetch("/api/search", {
query: { query: curInput, pageNo: curPage, pageSize: 10 },
immediate: !!query,
});
其中immediate: !!query
表示如果当前路径包含搜索词,则会请求数据,渲染结果页,否则不立即执行该请求,而是等一些响应式变量如curInput
、 curPage
发生变化后执行请求。
子组件部分这里就不详细解释了,具体可以查看源码,整体来说并不是很复杂。
其他
除此之外,我还增加了google analytics和百度 analytics,代码都非常简单,在plugins/
目录下,如果你需要使用该项目,记得将对应的id改为你自己的id。
最后
这次也算是第一次使用nuxt来开发一个应用,总体来说安装了nuxt插件之后的开发体验非常不错,按照目录规范写代码也可以少掉很多导入导出的一串串丑代码。
关于笔者--justin3go.com
来源:juejin.cn/post/7327938054240780329
又报gyp ERR!为什么有那么多人被node-sass 坑过?
前言
node-sass: Command failed.
, gyp ERR! build error
这几个词相信很多小伙伴一定看着眼熟,当你的终端出现这些词时那么毫无疑问,你的项目跑不起来了。。。。。。
你可能通过各种方式去解决了这个报错,但是应该没有人去深究到底是咋回事,接下来让我们彻底弄懂,再遇到类似问题时能够举一反三,顺利解决。
关键词:node-sass
libsass
node-gyp
先来看个截图感受一下
熟悉吧?截图里我们看到了几个关键词 node-sass
libsass
node-gyp
.cpp
,我们一一来解释一下
node-sass
node-sass
是一个用于在 Node.js 中编译 Sass
文件的库,node-sass
可以将 .scss 或 .sass 文件编译为标准的 .css 文件,供浏览器或其他工具使用。
- node-sass 可以看作是 libsass 的一个包装器(wrapper)或者说是 nodejs 和 libsass 之间的桥梁。
- 它提供了一个 Node.js 友好的接口来使用 libsass 的功能。
- 当你使用 node-sass 时,实际的 Sass 编译工作是由底层的 libsass 完成的。
当你的项目中使用 sass 来写样式时,会直接或间接地引入这个这个库。
libsass
一个用 C++
编写的高性能 Sass 编译器,这就是为什么你能在终端日志中看到 .cpp 的文件。
注意,搞这么麻烦就是为了高性能编译sass。
node-gyp
node-sass引入了 c++ 编写的库,那么直接在 node 中肯定是使用不了的,毕竟是两种不同的语言嘛,那么就需要 node-gyp 登场了。
node-gyp 是一个用于编译和构建原生
Node.js 模块的工具,这些原生模块通常是用 C++ 编写的。
node-sass 需要在安装时编译 libsass 的 C++ 代码,以生成可以在本地机器上运行的二进制文件。在这个编译过程中,node-gyp
就被用作构建工具,它负责调用 C++ 编译器(如 g++ 或 clang++),并将 libsass 的源代码编译为与当前系统兼容的二进制文件。
node-gyp 本身是一个用 JavaScript 编写的工具。它使用 Node.js 的标准模块(如 fs 和 path)来处理构建和配置任务,但是需要一些外部工具来实际进行编译和构建,例如 make、gcc等,这些工具必须安装在系统中,node-gyp 只是协调和使用这些工具。
普通模块(JavaScript/TypeScript)
普通模块是用 JavaScript 或 TypeScript 编写的,Node.js 本身可以直接执行或通过编译(如 TypeScript 编译器)转换为可执行代码,Node.js 使用 V8 引擎执行 JavaScript 代码。
原生模块(C/C++ 编写)
原生模块是用 C/C++ 编写的代码,这些模块通常用于高性能需求或需要直接与底层系统 API 交互的场景。它们通过 node-gyp 进行编译,并在 Node.js 中以二进制文件的形式加载。
例如以下模块:
- node-sass:编译 Sass 文件,依赖 libsass 进行高性能的样式编译。
- sharp:处理图像文件,使用 libvips(c++库) 提供高效的图像操作。
- bcrypt:进行加密处理,提供安全的密码哈希功能。
- fsevents:用于 macOS 系统的文件系统事件监听。
许多现有的高性能库和工具是用 C++ 编写的。为了利用这些库的功能,Node.js 可以通过模块接口调用这些 C++ 库,尤其某些高级功能,如加密算法、图像处理等,已经在 C++ 中得到了成熟的实现,Node.js 可以直接集成这些功能,而不必再重新造轮子而且性能还肯定不如用 c++ 写的库。
所谓的原生模块接口就是 node-gyp(还有Node-API (N-API)等),感兴趣的可以看看 node-gyp 的实现,或者了解下包信息
npm info node-gyp
node-gyp 基于 gyp,并在其之上添加了 Node.js 特定的功能,以支持 Node.js 模块的编译。
gyp
gyp(Generate Your Projects)是一个用于生成构建配置文件的工具,它负责将源代码(C++ 代码)转换为特定平台的构建配置文件(如 Makefile、Visual Studio 项目文件等),生成构建文件后,使用生成的构建文件来编译项目。例如,在 Unix 系统上,运行 make;在 Windows 上,使用 Visual Studio 编译项目。
从前端的角度来看,你可以把gyp理解成 webpack,而 make 命令则是 npm run build
为什么报错?
现在,我们已经了解了一些基本概念,并且知道了c++原生模块是需要在安装时编译才能使用的,那么很明显,上面的错误就是在编译时出错了,一般出现这个错误都是因为我们有一些老的项目的 node 版本发生了变化,例如上面的报错就是,在 node14 还运行的好好的,到了 node16 就报错了,我们再来看一下报错细节
这个错误发生在编译 node-sass 的过程中,具体是在编译 binding.cpp 文件时。
binding.cpp 是 node-sass 的一部分,用于将 libsass 与 Node.js 接口连接起来。
报错发生在 v8-internal.h 文件里,大概意思是这个c++头文件里使用了比较高级的语法,node-sass中的c++标准不支持这个语法,导致报错,就相当于你在 js 项目里引入一个三方库,这个三方库使用了 es13 的新特性(未编译为es5),但是你的项目只支持 es6,从而导致你的项目报错。
所以解决思路就很清晰了,要么降级 node 版本,要么升级 node-sass,要么干脆就不用node-sass了
解决方案
rebuild 大法
npm rebuild node-sass
重新编译模块,对于其他编译错误该方法可能会有效果,但是如果 node-sass 和 node 版本不匹配那就没得办法了,适合临时使用,不推荐。
升级node-sass
以下是 node-sass 支持的最低和最高版本的指南,找对应的版本升级就好了
但是!node-sass 与node版本强绑定,还得去想着保持版本匹配,心智负担较重,所以使用 sass
更好
更换为 sass
官方也说了,不再释放新版本,推荐使用 Dart Sass
Sass 和 node-sass 的区别
实现语言:
- Sass:Sass 是一个用 Ruby 编写的样式表语言,最初是基于 Ruby 的实现(ruby-sass)。在 Ruby 版本被弃用后,Sass 社区推出了 Dart 语言实现的版本,称为 dart-sass。这个版本是当前推荐的实现,提供了最新的功能和支持。
- node-sass:node-sass 封装了 libsass,一个用 C++ 编写的 Sass 编译器。node-sass 使用 node-gyp 编译 C++ 代码和链接 libsass 库。
构建和编译:
- Sass:dart-sass 是用 Dart 语言编写的,直接提供了一个用 JavaScript 实现的编译器,因此不需要 C++ 编译过程。它在 Node.js 环境中作为纯 JavaScript 模块运行,避免了编译问题。
- node-sass:node-sass 依赖于 libsass(C++ 编写),需要使用 node-gyp 进行编译。很容易导致各种兼容性问题。
功能和维护:
- Sass:dart-sass 是 Sass 的官方推荐实现,拥有最新的功能和最佳的支持。它的更新频繁,提供了对最新 Sass 语言特性的支持。
- node-sass:由于 node-sass 依赖于 libsass,而 libsass 的维护在 2020 年已停止,因此 node-sass 逐渐不再接收新的功能更新。它的功能和特性可能滞后于 dart-sass。
性能:
- Sass:dart-sass 的性能通常比 node-sass 更佳,因为 Dart 编译器的性能优化更加现代。
- node-sass:虽然 libsass 在性能上曾经表现良好,但由于它不再更新,可能不如现代的 Dart 实现高效。
总结
gyp ERR!
和 node-sass
问题是由 node-sass 与 node 版本兼容引起的编译问题,推荐使用 sass
替代,如果老项目不方便改的话就采用降级 node 版本或升级 node-sass 的办法。
其实这种问题没太大必要刨根究底的,但是如果这次简单解决了下次遇到类似的问题还是懵逼,主要还是想培养自己解决问题的思路,再遇到类似天书一样的坑时不至于毫无头绪,浪费大量时间。
来源:juejin.cn/post/7408606153393307660
记一次让我压力大到失控的 bug
事情经过
前两天线上发生了结算的漏洞,这里的代码是我写的,出问题的时候是周日晚上,那天大领导打电话过来问我具体的损失情况。
最后查出来是有两个人逮到了系统漏洞,一共 87 笔订单出现了多结算的问题,薅了大概 2.6 w,有个人当时已经跑了,还有个账户里面只有几百块钱。
发现问题后紧急停止提现,其他的明天上班再处理。
但我当晚已经无法入睡了,压力非常非常大。
普通开发和项目负责人最大的区别,可能是后者要承担风险和责任,但这个项目就我一个开发,我要同时兼顾两种角色。
周一早上去上班,前一天晚上也没睡,精神状态可想而知,浑浑噩噩的,先把问题和场景分析清楚,最终发现是用户在加价的时候回写了订单状态,导致重复结算。紧急加上各种校验,然后把所有相关的接口全部加强校验。
代码还没写完,就被拉去会议室开批斗会了,直属领导和大领导怼着我一顿骂,诸如什么高级开发、别人犯这种错误早滚蛋了,我还不识趣的解释了下具体原因,结果被骂的更凶了。
所以记住了,以后犯错了被骂,不要说为什么出错,只要认错,再加上解决时间。
心态更差了,压力可想而知,花了一天干到凌晨,修复上线。
结果过了几天,又出现重复问题了。
仔细一看真是倒了血霉,底层架构分布式锁写的有问题,没锁住请求,在 finally 用了 redission 的 forceunlock,导致并发场景下当前线程的锁被别的线程释放了。
然后就有了第二次批斗会,这次简短扼要得多,大致就说了两句,今天必须修复完成,再出问题自己提离职申请。
回到工位先修好了分布式锁,又在资金流水加了道数据库锁。又是干到凌晨,加上中间还有个比较紧急的需求,有几次干到了凌晨一两天。
到今天事情过去了快一周。
回想那几天的经历,晚上基本上没睡觉,压力非常大,到凌晨三四点都是常态,对身体和精神都是非常强烈的折磨,辞职的念头此起彼伏。对 bug 也有了 PTSD。
昨天晚上商务同事告诉我已经追回了 1.5w,预计可以追回 2.4w,后面的分批追回。
真心感谢商务同事,也给别人造成了不少麻烦。
心理感悟
不知道你如果遇到我经历的情况有什么感受。希望你不要有我这么大的压力。不过这次压力也让我更深刻的体会到了一些事。
就一份工作而已
就一份工作而已,大不了辞职不干了嘛,不要给自己那么大的压力,压垮了身体,公司也不会负责,受伤的还是自己。
人得自私一点,多为自己想想,少点集体荣誉感责任心什么的,任何一份工作都是自己成长的垫脚石。
要成为工作的主人,不要反被工作拿捏了。
我之前觉得公司给我工资了,我得好好为它卖命。
现在看看自己这些年写的系统,今年已经给公司创造了 600w+ 的GMV,尽管还有其他商务、运营同事共同点功劳,但我的工资是远远远远不及这个的,从这个角度看,公司应该感谢我这么多年辛苦工作。
最重要永远是身体、亲人和朋友
人生说长不长,说短不短。大家都是几十年时间,身外之物,生不带来,死不带走。
自己的身体健康才是最重要的,不要等身体垮了再追悔莫及,很多健康问题是无法恢复只能缓解的,医学没我们想的那么发达。
其次是家人、伴侣、朋友。想想自己出生和去世的时候,身边会有哪些人?这时候工作在哪里呢?
我以前对家人很不耐烦,现在我对他们有耐心了很多,连老婆都觉得我 “好欺负” 了哈哈哈,或许说再多都不会有用,直到自己刻骨铭心的经历一波。
业务、公司、地球没了你照样转,但是亲人、爱人、朋友没了你,他们会很伤心。
心态非常重要
出天大的 bug,业务也不会凉,公司也不会倒闭,天也不会塌,地球不会停止自转,太阳不会熄灭,更重要的是,写 bug 的人不会死。
极大的压力、焦虑并不会让我把事情做的更好,像我这种搞的几晚上睡不了的人,再去修 bug,效率真不如美美睡一觉再去。
现在回过头看,自己那么大压力完全是自己给的,像是自残。人得对自己好一点,不要给自己那么大的压力。
还有领导说的什么自己提离职、这么低级的错误等等骂人的话,别太往心里去,他也是气头上找个东西宣泄下,或者是想告诉你这很重要必须搞好,这是一种情绪的表达。
你别管他说什么,你现在就是他的出气筒,等他骂完你只要表达你必须解决点问题的态度和解决的时间点就行了。
给他点情绪价值。领导也有更大的领导要汇报内容,到时候指不定他被骂的更凶呢哈哈。
付出行动
纠正完认知和心态,还是要回归到行动上,如何避免类似的情况再发生,从失败中学习。
像这次结算出问题后,我真正理解了结算所涉及的各种细节和要点,以后再写的任何项目,结算都不可能再出问题了。
感兴趣的同学可以翻翻我前两篇动态,有关于结算系统设计的文档,后面我还会模拟订单场景,结合建表语句做个详细的结算架构设计。
对我来说,事情解决才会让我安心。
就像我必须把系统里涉及结算的场景、代码重新梳理清楚,搞明白了,保证下次不可能再出问题了,我才会真正的没有压力。
我希望你知道这很重要,只是把心态调整好而不去真正的解决问题,下次还是会出问题,甚至会有不知道什么时候会出问题的恐惧。
解决问题需要付出精力和行动,这可能更难,但这才是人成长和进步的原因。
加油,共勉。
来源:juejin.cn/post/7450700990389305396
Tauri+MuPDF 实现 pdf 文件裁剪,侄子再也不用等打印试卷了🤓
基于
MuPDF.js
实现的 PDF 文件 A3 转 A4 小工具。(其实就是纵切分成2份🤓)
开发背景
表哥最近经常找我给我侄子的试卷 pdf
文件 A3 转 A4(因为他家只有 A4 纸,直接打印字太小了)。
WPS
提供了pdf
的分割工具,不过这是会员功能,我也不是总能在电脑前操作。于是我想着直接写一个小工具,拿Tauri
打包成桌面应用就好了。
在掘金里刷到了柒八九大佬的文章:Rust 赋能前端:PDF 分页/关键词标注/转图片/抽取文本/抽取图片/翻转... 。发现MuPDF.js
这个包有截取pdf
文件的API
,并且提供了编译好的wasm
文件,这意味着可以在浏览器中直接体验到更高的裁切性能,于是我果断选择了基于MuPDF
开发我的小工具。
项目简介
MuPDF-Crop-Kit
是一个基于MuPDF.js
、React
、Vite
和Tauri
开发的小工具,用于将 PDF 文件从 A3 纸张大小裁切为 A4 纸张大小。它具有以下特点:
- 免费使用:无需任何费用;
- 无需后台服务:可以直接在浏览器中运行,无需依赖服务器;
- 高性能:利用 WebAssembly (WASM) 技术,提供高效的文件裁切性能;
- 轻量级桌面应用:通过 Tauri 打包成桌面软件,安装包体积小,方便部署;
- 开源项目:欢迎社区贡献代码和建议,共同改进工具。
项目代码地址
Github
:MuPDF-Crop-Kit
开发过程与踩坑
MuPDF.js
只支持ESM
,官网中给出的要么使用.mjs
文件,要么需要项目的type
改成module
:
npm pkg set type=module
我在我的
Rsbuild
搭建的项目中都没有配置成功🤷♂️,最后发现用Vite
搭建的项目直接就可以用...- 因为没有直接提供我想要的功能,肯定是要基于现有的
API
手搓了。但是截取页面的API
会修改原页面,那么自然想到是要复制一份出来,一个截左边一个截右边了。但是MuPDF.js
的copyPage
复制出来的pdf
页修改之后,原pdf
页居然也会被修改。
于是我想到了,一开始就new
两个PDFDocument
对象,一个截左边一个截右边,最后再合并到一起,我很快实现了两个文档的分别截取,并通过转png
图片之后再合并,完成了裁切后的文档的浏览器预览。
然后我考虑直接使用jspdf
把png
图片转pdf
文件,结果2MB
的原文件转换后变成了12MB
,并且如果原文件是使用扫描全能王
扫描出来的,生成的pdf
文件会很糊。
最后,终于让我在文档中发现merge
方法:
不过依赖包提供的方法很复杂:
merge(sourcePDF, fromPage = 0, toPage = -1, startAt = -1, rotate = 0, copyLinks = true, copyAnnotations = true) {
if (this.pointer === 0) {
throw new Error("document closed");
}
if (sourcePDF.pointer === 0) {
throw new Error("source document closed");
}
if (this === sourcePDF) {
throw new Error("Cannot merge a document with itself");
}
const sourcePageCount = sourcePDF.countPages();
const targetPageCount = this.countPages();
// Normalize page numbers
fromPage = Math.max(0, Math.min(fromPage, sourcePageCount - 1));
toPage = toPage < 0 ? sourcePageCount - 1 : Math.min(toPage, sourcePageCount - 1);
startAt = startAt < 0 ? targetPageCount : Math.min(startAt, targetPageCount);
// Ensure fromPage <= toPage
if (fromPage > toPage) {
[fromPage, toPage] = [toPage, fromPage];
}
for (let i = fromPage; i <= toPage; i++) {
const sourcePage = sourcePDF.loadPage(i);
const pageObj = sourcePage.getObject();
// Create a new page in the target document
const newPageObj = this.addPage(sourcePage.getBounds(), rotate, this.newDictionary(), "");
// Copy page contents
const contents = pageObj.get("Contents");
if (contents) {
newPageObj.put("Contents", this.graftObject(contents));
}
// Copy page resources
const resources = pageObj.get("Resources");
if (resources) {
newPageObj.put("Resources", this.graftObject(resources));
}
// Insert the new page at the specified position
this.insertPage(startAt + (i - fromPage), newPageObj);
if (copyLinks || copyAnnotations) {
const targetPage = this.loadPage(startAt + (i - fromPage));
if (copyLinks) {
this.copyPageLinks(sourcePage, targetPage);
}
if (copyAnnotations) {
this.copyPageAnnotations(sourcePage, targetPage);
}
}
}
}
而且在循环调用这个
MuPDF.js
提供的merge
方法时,wasm
运行的内存被爆了🤣。
仔细阅读代码发现其核心实现就是:
addPage
新增页面;put("Resources")
复制原文档页面中的内容到新页面;insertPage
将新增的页面插入到指定文档中。
因为我并没有后续添加的
link
和annotation
,所以经过设计后,决定使用一个空的pdf
文档,逐页复制原文档两次到空白文档中。主要逻辑如下:
- 加载 PDF 文件:读取并解析原始 A3 PDF 文件。
- 复制页面:创建两个新的 PDF 文档,分别截取每页的左半部分和右半部分。
- 合并页面:将两个新文档中的页面合并到一个新的 PDF 文档中。
- 设置裁剪框:根据 A4 纸张尺寸设置裁剪框(CropBox)和修整框(TrimBox)。
export function merge(
targetPDF: mupdfjs.PDFDocument,
sourcePage: mupdfjs.PDFPage
) {
const pageObj = sourcePage.getObject();
const [x, y, width, height] = sourcePage.getBounds();
// Create a new page in the target document
const newPageObj = targetPDF.addPage(
[x, y, width, height],
0,
targetPDF.newDictionary(),
""
);
// Copy page contents
const contents = pageObj.get("Contents");
if (contents) newPageObj.put("Contents", targetPDF.graftObject(contents));
// Copy page resources
const resources = pageObj.get("Resources");
if (resources) newPageObj.put("Resources", targetPDF.graftObject(resources));
// Insert the new page at the specified position
targetPDF.insertPage(-1, newPageObj);
}
export function generateNewDoc(PDF: mupdfjs.PDFDocument) {
const count = PDF.countPages();
const mergedPDF = new mupdfjs.PDFDocument();
for (let i = 0; i < count; i++) {
const page = PDF.loadPage(i);
merge(mergedPDF, page);
merge(mergedPDF, page);
}
for (let i = 0; i < count * 2; i++) {
const page = mergedPDF.loadPage(i); // 使用 mergedPDF 的页码
const [x, y, width, height] = page.getBounds();
if (i % 2 === 0)
page.setPageBox("CropBox", [x, y, x + width / 2, y + height]);
else page.setPageBox("CropBox", [x + width / 2, y, x + width, y + height]);
page.setPageBox("TrimBox", [0, 0, 595.28, 841.89]);
}
return mergedPDF;
}
完成以上核心方法后,便可以成功将我侄子的试卷裁切为
A4
大小进行打印了✅。
体验与安装使用
浏览器版
- 直接访问网页链接(MuPdf-Crop-Kit)。
桌面版
- 下载并安装
Tauri
打包的桌面应用(Release tauri-x64-exe-0.1.0 · HyaCiovo/MuPdf-Crop-Kit); - 使用源码自行打包需要的安装包。
使用教程
使用本工具非常简单,只需几个步骤即可完成 PDF 文件的裁切:
- 选择需要裁切的 A3 PDF 文件;
- 点击裁切按钮;
- 下载裁切后的 A4 PDF 文件。
不足
- 项目所使用的
wasm
文件大小有10MB
,本工具真正用到的并没有那么多,但是优化需要修改原始文件并重新编译; - 浏览器端的性能受限,并且
wasm
运行可以使用的内存也是有限的; - 没有使用
Web Worker
,理论上转换这种高延迟的任务应当放在Woker
线程中进行来防止堵塞主线程。
替代方案
如果在使用过程中遇到问题或需要更多功能,可以尝试以下在线工具:
- Split PDF Down the Middle A3 to A4 Online:每小时可以免费转换3次,作者亲测好用👍。
来源:juejin.cn/post/7451252126255382543
9个要改掉的TypeScript坏习惯
为了提升TypeScript技能并避免常见的坏习惯,以下是九个需要改掉的坏习惯,帮助你编写更高效和规范的代码。
1. 不使用严格模式
错误做法: 不启用tsconfig.json中的严格模式。
正确做法: 启用严格模式。
原因: 更严格的规则有助于未来代码的维护,修复代码的时间会得到回报。
2. 使用 || 确定默认值
错误做法: 使用 || 处理可选值。
正确做法: 使用 ?? 运算符或在参数级别定义默认值。
原因: ?? 运算符只对 null 或 undefined 进行回退,更加精确。
3. 使用 any 作为类型
错误做法: 使用 any 类型处理不确定的数据结构。
正确做法: 使用 unknown 类型。
原因: any 禁用类型检查,可能导致错误难以捕获。
4. 使用 val as SomeType
错误做法: 强制编译器推断类型。
正确做法: 使用类型守卫。
原因: 类型守卫确保所有检查都是明确的,减少潜在错误。
5. 在测试中使用 as any
错误做法: 在测试中创建不完整的替代品。
正确做法: 将模拟逻辑移到可重用的位置。
原因: 避免在多个测试中重复更改属性,保持代码整洁。
6. 可选属性
错误做法: 将属性定义为可选。
正确做法: 明确表达属性的组合。
原因: 更明确的类型可以在编译时捕获错误。
7. 单字母泛型
错误做法: 使用单字母命名泛型。
正确做法: 使用描述性的类型名称。
原因: 描述性名称提高可读性,便于理解。
8. 非布尔判断
错误做法: 直接将值传递给 if 语句。
正确做法: 明确检查条件。
原因: 使代码逻辑更清晰,避免误解。
9. 感叹号操作符
错误做法: 使用 !! 将非布尔值转换为布尔值。
正确做法: 明确检查条件。
原因: 提高代码可读性,避免混淆。
来源:juejin.cn/post/7451586771781861426
低成本创建数字孪生场景-数据篇
众所周知在做数字孪生相关项目的时候,会划出相当一部分费用会用于做建模工作,然而有一天老板跑过来告诉我,由于客户地处于偏远经济欠发达地区,没有多少项目经费,因此不会花很大的价钱做建模,换言之如果这个项目连建模师都请不起了,阁下该如何应对。
常规的场景建模方式无非就是CAD、激光点云辅助、倾斜摄影建模,人工建模的成本自不必说,几百平方公理的场景如果用无人机倾斜等等建模也是一笔不小的开销,在客户对场景还原度和模型精细度不高的前提下,最省成本的办法还是尽肯能让程序自动建模。经过几天摸索,我终于找到一些稍微靠谱的应对方法,本篇着重讲述如何获取场景的数据,以及处理为展示所需的数据格式。
准备工作
使用的工具
工具 | 用途 | 成本 |
---|---|---|
水经注 | 获取全国GIS数据 | 部分免费 |
地理空间数据云 | 获取地形高程图 | 免费 |
QGIS | 空间数据编辑 | 开源 |
cityEingine | 自动生成建筑和道路模型 | 部分免费 |
CesiumLab3 | 转换地理模型 | 部分免费 |
CesiumJS | 3D地图引擎,用于做最终场景展示 | 开源 |
QGIS(全称Quantum GIS)是一个开源的地理信息系统, 提供了一种可视化、编辑和分析地理数据的途径。它支持各种矢量、栅格和数据库格式,并且支持丰富的GIS功能,包括地图制作、空间数据编辑、地图浏览等。
CesiumLab3是一款强大的3D地理信息数据处理软件,可以将各种类型的数据,如点云、模型、影像、地形等,转换为3D Tiles格式,以便在CesiumJS中可视化。此外,CesiumLab3还提供了一套完整的数据管理和分发服务,使得大规模3D地理信息数据的存储、管理和服务变得更加便捷。
CesiumJS是一款开源的JavaScript库,它用于创建世界级的3D地球和地图的Web应用程序。无论是高精度的地形、影像,还是高度详细的3D模型,CesiumJS都可以轻松地将它们集成到一个统一的地理空间上下文中。该库提供了丰富的接口和功能,让开发者可以轻松实现地理信息可视化、虚拟地球等应用。
基础数据
图层 | 数据形态 | 数据格式 | 文件格式 |
---|---|---|---|
卫星影像 | 图片 | 栅格 | TIF |
水域分布 | 多边形 | 矢量 | SHP |
建筑面轮廓 | 多边形 | 矢量 | SHP |
植被分布 | 散点 | 矢量 | SHP |
地形高程图 | 图片 | 栅格 | TIF |
数据处理
1. 影像和地形
带有地形信息的卫星影像作为底图可以说是非常重要,所有其他图层都是它的基础上进行搭建,因此我们必须尽可保证在处理数据时的底图和最终展示时使用的底图是一致的,至少空间参考坐标系必须是一致的。
由于在后面步骤用到的cityEgine工具可选坐标系里没有ESPG43226,这里使用EPSG32650坐标系。
在编辑过程中需要用到TIF格式的卫星底图,可以使用QGIS对栅格图层,图层的数据在平面模式下进行编辑,主要工作是对齐数据、查漏补缺。
地形处理步驟如下:
- 打开地理空间数据云,登录并进入“高级检索”
- 设置过数据集和过滤条件后会自动返回筛选结果,点击结果项右侧的下载图标即可下载。
- 在 QGIS 中打开菜单栏 Raster > Miscellaneous > Merge,并将下载的高程文件添加到 Input layers 中。
- 可以使用 Processing Toolbox 中的 Clip raster by mask layer 工具来裁剪高程图层,处理好之后导出TIF格式备用
- 使用esiumlab3做地形转换,在这里会涉及到转换算法的选择,VCG算法适合用于小范围,对精度要求高的地形;CTD算法则适合用于大范围,对精度要求低的地形。根据具体情况选择即可。地形具体步骤看 高程地形切片和制作水面效果
2. 建筑轮廓
建筑轮廓通常为矢量数据,在2D模式下它就是由多个内封闭多边形组成的图层,而在3D模式下可以对这些多边形进行挤压形成体力几何体,并在此基础上做变形和贴图,形成大量城市建筑的视觉效果。
这部分数据很难在互联网上拿到,即使有免费的渠道(比如OMS下载),数据也是较为老旧的或者残缺的,有条件的话建议让业主提供或者购买水经注VIP会员下载。即使这样,我们还需要使用QGIS等工具进行二次编辑。
操作步骤如下:
- 在QGIS处理数据,对数据进行筛选、补充、填充基本信息等处理,导出SHP格式数据,建议将卫星影像和地形底图一并导出,用于做模型位置对齐。为方便后面的自动化建模,需要将每个建筑面的基本信息(比如名称和建筑高度)录入到每个多边形中,如果对建筑高度要求没有那么精细也可以用QGIS自带的方法随机生成高度数值。
- 在cityEngine中新建工程(存放各种数据、图层、规则文件)和场景(从工程中抽取素材搭建场景)
- 使用cityEngine进行自动程序化建模,有了建筑面轮廓和高度,就可以直接使用规则生成建筑了,建筑的风格也是可以灵活配置的,比如商业区住宅区,或者CBD风格城乡结合部风格等等。 具体操作看 CityEngine-建筑自动化程序建模
- 将模型导出为FBX,并使用cesiumlab3转换为3Dtiles ,具体操作见 常见3D模型转3Dtiles文件。这里要注意空间参考必须设置为与在QGIS处理数据时的坐标系一致,这里有个坑的地方就是非授权(收费)用户是无法使用ENU之外的其他坐标系将FBX或OBJ文件转为3DTiles的,只能用SHP转出3DTiles白模。这就是为什么很多人反映导出3DTiles后放到地图里又对不上的原因。
3. 绿地和植被图层
绿地和植被图层为我们展示了特定区域内的自然或人工绿地及植被分布情况,包括公园、森林、草地、田地等各类植被区域。获取数据通常可以通过遥感卫星图像,或者通过地面调查和采集。这些数据经过处理后,可以以矢量或栅格的形式在GIS中进行展示和分析。
在本文案例中为了制作立体茂密树林的视觉效果,我们将植被图层数据转换为LOD图层,即在不同的地图缩放尺度下有不同的细节呈现。基本原理就是在指定的范围内随机生成一个树模型的大量实例操作步骤如下:
- 获取植被区域多边形,使用QGIS通过数据导入或手绘的方式得到植被的覆盖区域,可以給区域增加一些快速计算的属性,比如area
- 在覆盖区域内生成随机分布点,调整点的数量和密度达到满意状态即可
- 如有需要,可以手动调整随机生成的点数据,确认无误后导出文件shp
- 准备好带LOD的树模型(cesiumlab3\tools\lodmodel\yangshuchun有自带一个示例模型可用)和地形高程信息文件(.pak格式)
- 使用cesiumlab3创建实例模型切片,具体的流程可以看这里 CesiumLab3实例模型切片
以上方法适合创建模型单一、更新频度低、且数据量巨大的模型图层,比如树木、城市设备如路灯、垃圾桶、井盖等等。
4. 水域分布
地理上水域包括湖泊、河流、海洋和各种人工水体,在专业领域项目水域的分布对研究环境生态有重大意义,而比较通用的场景就是跟卫星影像、地形底图结合展示,我们同样需要矢量数据绘制多边形,并加上动态材质出效果。
由于最终展示的地图引擎cesium自带水域材质效果,这里的操作也变得简单,只要把水域多边形获取到手就行:
- 打开QGIS,导入从水经注下载的水域数据或者对着卫星影像地图手动绘制水域数据,导出为shp文件格式
- 在cesiumlab3 生成地形切片,在cesium里,水域是作为地形的一部分进行处理的,所以将地形高程图tif文件和水域shp文件一起上传处理即可。具体步骤看 高程地形切片和制作水面效果
组合数据
至此数据篇就介绍完了,由于cesiumlab3自带分发服务,我们可以直接在上面新建一个场景,将上文生成的数据图层组合到一个场景里作展示。另外还可以测试一些场景效果比如天气、轮廓、泛光等等,还挺有意思的。后续的单模型加载、可视化图层加载、鼠标事件交互等等就留在开发篇吧,今天就先这样。
- 叠加地形、建筑白模、植被实例切片图层
- 测试建筑模型积雪效果
相关链接
来源:juejin.cn/post/7329322608212885555
wasm真的比js快吗?
一. 前言
首先提一句话,本人是Rust
新手!如果有什么不足的地方麻烦指出哈!
最近一直在玩Rust
(摸鱼),本来是想着,多学一点,多练一练,之后把我们这边的一些可视化项目里面核心的代码用Rust
重构一下。但是我最近在练习一个demo的时候,发现了跟我预期不一样的地方。
具体如何,我用下面几个案例展开细说。
二. 案例1: 使用canvas绘制十万个不同颜色的圆
首先我想到的是,把canvas的复杂图像绘制功能用Rust
重写一下。这里我用canvas绘制大量的圆形为例子。
2.1 Rust绘制canvas
跟上一篇文章的流程差不多,我们需要先新建一个Rust
项目:
cargo new canvas_circel_random --lib
然后更新一下Cargo.toml
文件里面的依赖内容:
[package]
name = "canvas_circle_random"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["HtmlCanvasElement", "CanvasRenderingContext2d"] }
完成之后,我们简单在src/lib.rs
写一点代码:
// 引入相关的依赖
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
use js_sys::Math;
// 给js调用的方法
#[wasm_bindgen]
pub fn draw_circles(canvas: HtmlCanvasElement) {
// 获取ctx绘画上下文
let context = canvas.get_context("2d").unwrap().unwrap().dyn_int0::<CanvasRenderingContext2d>().unwrap();
let width = canvas.client_width() as f64;
let height = canvas.client_height() as f64;
// 循环绘制
for _ in 0..100_0000 {
// 设置一下写x,y的位置
let x = Math::random() * width;
let y = Math::random() * height;
let radius = 10.0;
let color = format!(
"rgba({}, {}, {}, {})",
(Math::random() * 255.0) as u8,
(Math::random() * 255.0) as u8,
(Math::random() * 255.0) as u8,
Math::random()
);
draw_circle(&context, x, y, radius, &color);
}
}
fn draw_circle(context: &CanvasRenderingContext2d, x: f64, y: f64, radius: f64, color: &str) {
// 调用canvas的API绘制
context.begin_path();
context.arc(x, y, radius, 0.0, 2.0 * std::f64::consts::PI).unwrap();
context.set_fill_style(&JsValue::from_str(color));
context.fill();
context.stroke();```
}
简单解释一下代码:
0..100_0000
创建了一个从0
开始到999,999
结束的范围注意,Rust 的范围是左闭右开的,这意味着它包含起始值但不包含结束值。&JsValue::from_str(color)
从变量中取值。
完成之后,我们去打包一下。
wasm-pack build --target web
然后我们得到一个pkg包,如下图:
然后我们在项目中引入一下,具体流程可以看我上一篇文章。
回到我们的Vue项目中,我们引入一下:
import init, { draw_circles } from 'canvas_circle_random/canvas_circle_random'
onMounted(async () => {
await init();
const begin = new Date().getTime();
drawWasmCircle();
const end = new Date().getTime();
console.log('wasm cost time: ' + (end - begin) + 'ms');
})
之后我们打开一下页面:
多次加载了几次,加载范围大概在2750ms~2900ms
之间。
2.2 使用js绘制canvas
const drawJsCircle = () => {
const canvas = document.getElementById('my-canvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
for (let i = 0; i < 1000000; i++) {
drawRandomCircle(ctx, 800, 600);
}
}
const drawRandomCircle = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
const radius = 10;
const x = Math.random() * (width - 2 * radius) + radius;
const y = Math.random() * (height - 2 * radius) + radius;
const color = `rgba(${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.floor(Math.random() * 256)}, ${Math.random().toFixed(2)})`;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.stroke();
}
没什么好说的,有手就会。
然后我们在页面上试一下:
加载范围大概在1950ms~2200ms
之间。
卧槽,难道说js的性能比wasm快???
然后我又对绘制的数量和绘制的形状做了多次实验:
绘制10000个圆
,wasm
用时大概在1000ms,js
用时大概在700ms。绘制100000个长方形
,wasm
用时大概在1700ms,js
用时在1100ms。
无一例外,在绘制canvas上面,js的性能确实优于wasm
。
三. 案例2:算法性能
考虑到通过canvas绘制图形来判断性能,有点太前端化了,我想可不可以通过写一些算法来做一下性能的比较。
试了很多算法,这里我用一下斐波那契算法
,比较简单也比较有代表性。
在同级目录下新建一个Rust
项目:
cargo new fb-lib --lib
然后在fb-lib中修改一下Cargo.toml
:
[package]
name = "fb-lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
把斐波那契数列代码写到src/lib.rs
文件中:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn fb_wasm(n: i32) -> i32 {
if n <= 1 {
1
}
else {
fb_wasm(n - 1) + fb_wasm(n - 2)
}
}
很简单,没什么好说的。完成之后,我们在项目中使用一下。
<script setup lang="ts">
import init, { fb_wasm } from 'fb-lib/fb_lib'
import { onMounted } from 'vue';
onMounted(async () => {
await init();
const begin = new Date().getTime();
fb_wasm(42);
const end = new Date().getTime();
console.log('wasm cost time: ' + (end - begin) + 'ms');
})
</script>
大概试了一下时间在1700ms~1850ms
左右。
然后我们用js实现一下:代码如下:
import init, { fb_wasm } from 'fb-lib/fb_lib'
import { onMounted } from 'vue';
onMounted(async () => {
await init();
const begin = new Date().getTime();
fn_js(42);
const end = new Date().getTime();
console.log('js cost time: ' + (end - begin) + 'ms');
})
const fn_js = (n: number): number => {
if (n <= 1) {
return 1;
} else {
return fn_js(n - 1) + fn_js(n - 2);
}
}
然后我们在页面上看一下:
大概试了一下时间在2550ms~2700ms
左右。
很明显,这时的wasm
的性能是要优秀于js
。
四. 总结
大概试了一下canvas
,dom操作
,高性能算法(排序、递归)
等。我大概得出了一个这样的结论:
Wasm代码比JavaScript代码更加精简
,因此从网络上获取Wasm代码的速度更快。- 对于一些高性能的算法,在基数足够大的情况下,
wasm
的性能确实高于js
,但是当基数比较小的时候,两者其实差不多。 - 由于Wasm是一种二进制格式,需要将
DOM操作的数据进行转换
,才能在Wasm
和js
之间进行传递。这个数据转换过程可能导致额外的开销
。以及Wasm操作DOM时
,需要通过js
提供的API进行通信。每次调用js
的API都会引入一定的开销,从而影响性能。所以在一些页面交互操作上,wasm
的性能并不会优于js
。
综上,个人觉得wasm
和js
之间是一种互相选择互相依靠的关系,并不是取代的关系。日常开发中,也要结合实际情况选择不同的方式进行开发。
往期文章:前端仔,用rust来写canvas
来源:juejin.cn/post/7444450769488674825
8年前端,那就聊聊被裁的感悟吧!!
前端开发,8年工作经验,一共呆了2家公司,一个是做积分兑换的广告公司。这是一个让我成长并快乐的公司,并不是因为公司多好,而是遇到了一群快乐的朋友,直到现在还依旧联系着。
另一家是做电子签名的独角兽,我人生的至暗时刻就是在这里这里并不是说公司特别不好,而是自己的际遇
我的经历
第一家公司
第一家公司说来也巧,本来是准备入职一家外包的,在杭州和同学吃个饭,接到了面试通知,一看地址就在楼上,上去一共就3轮面试,不到2个小时直接给了offer。有些东西真的就是命中注定
第二家公司
第二家公司我入职以后挖了上家公司诸多墙角,我一共挖了6个前端,2个后端。拯救朋友们于水深火热之中。

我本以为我能开启美好的新生活,结果第二年就传来我父亲重病的噩耗 肺癌晚期,我学习了大量的肺癌知识什么小细胞,非小细胞,基因检测呀等等。。。

可是最后还是没有挽留住他的生命,我记得我俩在最后一次去武汉的时候,睡在一起,他给我说了很多。
他说:治不好就算了,只是没能看到自己的孙子有些可惜罢了。
他说:我这一辈碌碌无为,没给你带来多么优越的条件,结婚、买房、工作都没给到任何帮助,唯一让我感到欣慰的是你那么努力,比我强多了,家里邻居很多都眼馋你呢。
他说:你小孩的名字想好了吗?你媳妇真是个孝顺的孩子,性格也好,心地善良,你要好好对待她。
他说了很多。。。我都快忘了他说了啥了,我不想忘来着,可是可是,想起来就又好难过。
这只是我人生历程的一部分,我把这些讲出来,是为了让大家明白,你现在所经历的困苦其实没有那么严重,人在逆境之中会放大自己的困难,以博得同情。所以现在很多人给我倒苦水的时候,我总有点不屑一顾的感觉,并不是我有多强,我只是觉得都能过去。
在灰暗的时候,工作总是心不在焉,情绪莫名冲动,我和领导吵过架,和ui妹妹撕破脸,导致人家天天投诉我。我leader说我态度极其嚣张,我说你再多说一句,我干死你所以不裁我裁谁。
我的人生感悟
我时常以我爸的角度换位思考,我在得知这个消息后我该咋办?是积极面对,还是放弃治疗?可是所有的都是在假设的前提之下,一切不可为真。只有在其中的才最能明白其中的感受。
那一年我看着他积极想活着的毅力,也看到了他身体日渐消瘦的无奈,无奈之余还要应付各种亲戚的嘘寒问暖。
我现在很能明白《天道》中那段,丁元英说的如果为了孝顺的名声,让父亲痛苦没有尊严地活着,还不如让父亲走了。 的意思了。在他昏迷不醒的时候,大小便失禁的时候,真不如有尊严的走了。
我其实已经预感到自己要被裁,我原本是挺担心的,可是后来想想父亲的话,我总结成一句话圆滑对事,诚以待人。 这句话看上去前后矛盾,无外乎俩个观点。
圆滑对事的意思是:就是要学会嘴甜,事嘛能少干就少干,能干几分是几分,累的是别人,爽的是自己,在规则中寻求最大的自我利益化。
诚以待人的意思是:圆滑归圆滑,不能对谁都圆滑,你得有把事情办的很好的能力,你需要给真正需要的人创造价值,而不是为了给压榨者提供以自我健康为代价的价值。
用现在最流行的词来说就是「佛系」。
什么叫活明白了,通常被理解为不争不抢,得之淡然、失之泰然、顺其自然的一种心理状态。
活明白的人一般知道自己要什么样的生活,他们不世故、不圆滑,坦荡的、磊落的做自己应该做的事儿。他们与社会上潜规则里的不良之风格格不入,却不相互抵触,甚至受到局中人的青睐与欣赏。
活明白的人看着更为洒脱,得不张扬,失不气馁,心态随和、随遇而安。
不过,还有一种活明白的人,不被多数人所接受。他们玩世不恭、好吃懒做,把所有一切交给命运去背锅。这种人极度自我,没有什么可以超越他自己的利益,无法想象这种活法,简直就是在浪费六道轮回的名额。
总之,有的人活明白了,是调整自己的心态,维护社会的稳定和安宁。有的人活明白了,是以自我为中心,一边依赖着社会救济,一边责备社会龌蹉。
所以,活明白的人也分善与恶,同样是一种积极向善,另一种是消极向恶,二者同出而异名。
我对生活的态度
离职的第一个月,便独自一人去了南京,杭州,长沙,武汉,孝感。我见了很多老朋友,听听他们发发牢骚,然后找一些小众的景点完成探险。
在南京看了看中医,在杭州露营看了看日落,在长沙夜爬了岳麓山,在武汉坐了超级大摆锤,在孝感去了无名矿坑并在一个奶奶家蹭了中午饭。
我的感受极其良好,我体验了前所未有生活态度,我热情待人,嘻嘻笑笑,我站在山顶敞怀吹风,在无尽的树林中悠然自得,治愈我不少的失落情绪。我将继续为生活的不易奔波,也将继续热爱生活,还会心怀感恩对待他人,也会圆滑处事 事事佛系。
可能能解决你的问题
要不要和家里人说
我屏蔽了家里人,把负面情绪隐藏,避免波及母亲本就脆弱的内心世界,我还骗她说公司今年不挣钱,提前让我们放假,只给基础工资。如果你家境殷实,家庭和睦,我建议大方的说,这样你和父母又多了一个可以聊的话题,不妨和他们多多交流,耐心一些。
裁员,真不是你的问题
请记住,你没有任何问题,你被裁员是公司的损失,你不需要为此担责,你需要做的是让自己更强,不管是心理、身体还是技术,你得让自己变得精彩,别虚度了这如花般的时光。可能你懒,可能也没什么规划,那就想到啥就做啥好了,可能前几次需要鼓足干劲,后面就会发现轻而易举。
如何度过很丧的阶段
沮丧需要一个发泄的出口,可以保持运动习惯,比如日常爬楼梯、跑步等,一场大汗淋漓后,又是一个打满鸡血积极向上的你。
不要总在家待着,要想办法出门,多建立与社会的联系,多和朋友吹吹牛逼,别把脸面看的那么重要,死皮赖脸反而是一种讨人喜欢的性格。
不管环境怎样,希望你始终向前,披荆斩棘
如果你也正在经历这个阶段,希望你放平心态,积极应对
如果你也在人生的至暗时刻,也请不要彷徨,时间总会治愈一切
不妨试试大胆一点,生活给的惊喜也同样不少
我在一个冬天的夜晚写着文字,希望能对你有些帮助
进群聊:技术、段子我都有

群已满,请加15757172732入群
来源:juejin.cn/post/7331657679012380722
原来科技感的三维地图可以这么简单实现
前言
2024.02.20
下午摸鱼时接到一个客户的数字孪生项目的需求,客户什么也没说,就要求“炫酷”和“科技感”地图,还要把他们的模型都放上去,起初我以为又是一个可视化大屏的项目,准备用高德地图应付过去,然后他们又在群里发什么要求高之类的,我们的数据种类多,说什么高德、百度、Mapbox、Cesium之类框架都试过了,满足不了需求,好嘛,这下给我犯了难了,会的技术栈全都给我排除了, 手撸Three.js源码我可不干,于是就在网上晃悠,尝试找一些灵感
2024.02.24
又试了几个地图框架,还是不行,被客户和经理催了一顿,再不行他们要换人了
2024.02.28
在QQ群里闲逛,发现了群友发的一个叫 Mapmost SDK for WebGL 的地图框架,于是抱着试一试的态度做了一下,好家伙,一下就对客户味了
2024.03.04
后面我发现这个SDK蛮有意思,于是把我实现客户需求的过程记录下来,分享给大家
初始化
这个SDK看似是个商用软件,不过目前是免费试用,官网上申请一下就行了,然后按照他们的文档,填一下参数,就能初始化一个地图,和一般的地图SDK用法差不多
<script src ='https://delivery.mapmost.com/cdn/sdk/webgl/v3.5.0/mapmost-webgl-min.js'></script>
<script>
let mapmost = window.mapmost
/*
* 初始化地图
*/
let map = new mapmost.Map({
container: 'map', //地图容器
style:'http://192.168.126.44/mms-style/darkMap.json', //矢量底图
center: [120.74014004382997, 31.32975410974069], //地图中心点
bearing: 50.399999999999636, //方位
pitch: 78.99999999999993, //倾斜角
zoom: 19.964625761228117, //缩放
userId: '***',
})
</script>
不过,在此之前,要把底图中的矢量建筑图层隐藏掉,客户要加载真正的建筑三维模型。
代码和效果图如下:
const buildingLayers = [
'buildings-di',
'buildings-faguang-1',
'buildings-faguang-2',
'buildings-faguang-3',
'buildings-high',
'buildings-high-top',
'buildings-low',
'buildings-low-top',
'buildings-lowmid',
'buildings-lowmid-top',
'buildings-mid',
'buildings-mid-top',
'buildings-midhigh',
'buildings-midhigh-copy',
]
map.on('load', (e) => {
buildingLayers.forEach((layer, index) => {
let layerObj = map.getLayer(layer)
map.setLayoutProperty(layerObj.id, 'visibility', 'none');
})
})
加载建筑三维模型
这里我们准备了城市建筑的模型,格式为glb
,按照文档描述,我们添加一个id
为modelLayer
的三维图层,代码和效果图如下
//...
/*
* 加城市f载建筑模型
*/
let Gr0up = null
let Layer = null
let models = ["./model/YQ.glb"].map(item => ({
type: 'glb',
url: item
}));
map.on('load',(e) => {
let modelOptions = {
id: 'modelLayer',
type: 'model',
models: models,
sky: "./sky/satara_night_no_lamps_2k.hdr",
exposure: 2.4,
center: [120.74155610348487, 31.328251735532746, 0],
callback: (group, layer) => {
Gr0up = group
Layer = layer
}
};
map.addLayer(modelOptions);
})
添加三维效果
接下来就是客户的G点了,为三维场景添加特效:
添加建筑流光渐变效果
参考SDK的接口文档,给建筑加上流光渐变的效果
定义一个添加特效的函数addModelEffect
,然后按照文档上的参数说明来配置相关属性
const addModelEffect = () =>{
Layer.addModelEffect(Gr0up, [{
type: "gradient",
startColor: "rgba(63, 177, 245,.5)",
endColor: "rgba(58, 142, 255,.8)",
opacity: 0.8,
percent: 0.5
}, {
type: "flow",
speed: 1,
color: "rgba(241, 99, 114, .4)",
opacity: 0.8,
percent: 0.05
}])
Gr0up.addFrame(0x3FB1F5);
}
然后我们在模型加载完成后调用这个函数:
//...
map.on('load',(e) => {
let modelOptions = {
id: 'modelLayer',
type: 'model',
models: models,
sky: "./sky/satara_night_no_lamps_2k.hdr",
exposure: 2.4,
center: [120.74155610348487, 31.328251735532746, 0],
callback: (group, layer) => {
Gr0up = group
Layer = layer
addModelEffect()
}
};
map.addLayer(modelOptions);
})
效果如下:
添加粒子飞线
同样,在SDK的文档上找到添加流动线的接口,定义一个addFlowLine
的函数,然后按照要求配置参数:
这里我们借助了一个生成贝塞尔曲线的函数,以及一些随机的坐标点位数据。
他们的作用是为我们提供必要的模拟数据
import { getBSRPoints } from './bezierFunction.js'
import { flowLineData } from './flowLineData.js'
//...
const addFlowLine = () => {
//生成贝塞尔曲线测试数据
let data_trail1 = getBSRPoints(120.71541557869517, 31.316503949907542,
120.73787560336916, 31.321925190347713, 800);
let data_trail2 = getBSRPoints(120.71541557869517, 31.316503949907542,
120.72619950480242, 31.33360076088249, 1500);
let data_trail3 = getBSRPoints(120.71541557869517, 31.316503949907542,
120.69933418653403, 31.332725809914024, 900);
[data_trail1, data_trail2, data_trail3].map(data => {
Layer.addFlowLine({
type: "trail",
color: '#1ffff8',
speed: 4,
opacity: 0.9,
width: 8,
data: {
coordinate: data
}
});
})
flowLineData.map(data => {
Layer.addFlowLine({
type: "flow",
color: '#ff680d',
speed: 4,
opacity: 1,
percent: 0.08,
gradient: 0.02,
width: 5,
data: {
coordinate: data
}
});
})
}
同样,我们在模型加载完成后调用这个函数:
//...
map.on('load',(e) => {
let modelOptions = {
id: 'modelLayer',
type: 'model',
models: models,
sky: "./sky/satara_night_no_lamps_2k.hdr",
exposure: 2.4,
center: [120.74155610348487, 31.328251735532746, 0],
callback: (group, layer) => {
Gr0up = group
Layer = layer
addModelEffect()
addFlowLine()
}
};
map.addLayer(modelOptions);
})
效果图如下:
添加特效球
类似的,文档上的添加特效球的接口,给场景里添加两个”能量半球“
代码和效果图如下:
const addSphere = () => {
let sphere = Layer.addSphere({
color: "rgb(53, 108, 222)",
radius: 3300, //半径,单位米,默认为10米
segment: 256, //构成圆的面片数
phiLength: Math.PI,
speed: 3,
opacity: 1,
center: [120.67727020663829, 31.31997024841401, 0.0]
});
let sphere2 = Layer.addSphere({
color: "rgb(219, 74, 51)",
radius: 2300, //半径,单位米,默认为10米
segment: 256, //构成圆的面片数
phiLength: Math.PI,
speed: 6,
opacity: 1,
center: [120.67727020663829, 31.31997024841401, 0.0]
});
}
//...
map.on('load',(e) => {
let modelOptions = {
id: 'modelLayer',
type: 'model',
models: models,
sky: "./sky/satara_night_no_lamps_2k.hdr",
exposure: 2.4,
center: [120.74155610348487, 31.328251735532746, 0],
callback: (group, layer) => {
Gr0up = group
Layer = layer
addModelEffect()
addFlowLine()
addSphere()
}
};
map.addLayer(modelOptions);
})
环境效果调优
仔细看整个环境,发现天是白的,和整体环境不搭配
更改一下地图初始化时的参数,将天空设置为暗色:
let map = new mapmost.Map({
container: 'map', //地图容器
style: 'http://192.168.126.44/mms-style/darkMap.json', //矢量底图
center: [120.74014004382997, 31.32975410974069], //地图中心点
bearing: 60.399999999999636, //方位
pitch: 78.99999999999993, //倾斜角
zoom: 14.964625761228117, //缩放
sky: 'dark' //天空颜色
})
然后整体效果如下:
如果觉得场景本身太亮,可以降低添加模型时的曝光度:
let modelOptions = {
exposure: .4,
callback: (group, layer) => {
//...
}
};
这样整体环境就会偏暗一点,更有黑夜下的赛博朋克城市的味道
当然,在这我又换了一张更暗的底图:
最后,再调整一下特效球的半径和位置,行了,这就是客户喜欢的样子,哈哈哈,2小时搞定,而且不用手撸Three.js代码:
const addSphere = () => {
let sphere = Layer.addSphere({
color: "rgb(53, 108, 222)",
radius: 3300, //半径,单位米,默认为10米
segment: 256, //构成圆的面片数
phiLength: 180,
speed: 3,
opacity: 1,
center: [120.67943361712065, 31.306450929918768]
});
let sphere2 = Layer.addSphere({
color: "rgb(219, 74, 51)",
radius: 2300, //半径,单位米,默认为10米
segment: 256, //构成圆的面片数
phiLength: 180,
speed: 6,
opacity: 1,
center: [120.67727020663829, 31.31997024841401, 0.0]
});
}
总结
仔细看,不难发现,这个SDK集成了 Mapbox 和 Three.js 的核心功能,主打的是一个高颜值的三维地图引擎,当然除了好看之外,其他地图框架该有的功能它也具备,只是官网给人的感觉过于粗糙,不够吸引人;另外产品试用的门槛有些高,希望后面能优化吧
来源:juejin.cn/post/7342279484488138802
盘点一下用了这么长时间遇到的Wujie 无界微前端的坑
目前也用无界微前端很长时间了,当时选择无界没有选择乾坤的原因就是无界的保活模式更加丝滑,客户就想要这种方式,但是在这段过程也遇到了很多问题,甚至有些就是无界目前解决不了的问题,所以希望大家今后遇到了也能提前避免
已经解决的问题
1、子应用使用wangEditor 在主应用中无法编辑 ,无法粘贴,工具栏无法正常使用
✨问题复现
- 子项目中正常
- 主项目无法选中,无法粘贴
✨出现这种问题的原因:
- 子应用运行在 iframe内,dom在主应用的shadowdom中,当选中文字时,在主应用监听selectionchange,并且通过 document.getSelection()获取选中的selection,在wangEditor中 会判断这个 selection instanceof window.Selection,很明显主应用的selection 不可能是 iframe 里面window Selection的实例,所以出现了问题
- shadowDom 大坑,在shadowDom中 Selection.isCollapsed永远为true,相当于永远没有选中,所以只能修改 wangEditor 的代码,让读取 isCollapsed 修改成 baseOffset 和 focusOffset的对比,就知道是否选中了文字了
✨解决方案
1、将 wangeditor 替换成 wangEditor-next,因为wangeditor 作者大大已经说因为种种原因后面不会再继续更新了,所以担心更新的同学可以使用 wangEditor-next 这个还有人在继续更新
2、直接替换一个富文本组件 vue-quill,我使用的就是这个,因为我们项目对富文本的需求没有那么重,只要能用就行,所以你也可以替换一个在无界中没有问题的富文本组件
3、由此我们知道了在无界中getSelection是有问题的 如果遇到了可以用这个插件尝试解决 wujie-polyfill.github.io/doc/plugins…
2、子应用使用vue-office 的 pdf 预览,在主应用中白屏
✨问题复现
- 子应用 正常显示
- 主应用直接加载不出来
✨解决方案:
直接换个轮子,因为vue-office 源码不对外开放,你根本不知道是它内部做了何种处理,所以最好的办法就是直接换个其他的能够预览pdf 的方式,我这边选择的是 kkfile 的方式,kkfile 不仅可以预览pdf 还可以预览很多其他的格式的文件,让后端去生成预览地址 然后前端直接用 iframe 去打开即可
3、开发环境下 vite4 或者 vite5 子应用的 element-plus的样式丢失或者自己写的:root 不生效
✨问题复现
- 子应用正常
- 主应用样式丢失
✨出现这种问题的原因:
主要的原因是因为子应用的 :root 失效了,因为无界中是将:root 转成了:host ,但是如果你是在main.js 中外链的样式
import 'element-plus/dist/index.css'
这样的话无界将无法劫持到将 :root 转成:host
✨解决办法:
增加插件
<WujieVue
width="100%"
height="100%"
name="pro1"
:url
:sync="true"
:exec="true"
:alive="true"
:props="{ jump }"
:plugins="[{
patchElementHook(element, iframeWindow) {
if (element.nodeName === "STYLE") {
element.insertAdjacentElement = function (_position, ele) {
iframeWindow.document.head.appendChild(ele);
};
}
},
}]"
></WujieVue>
如果不生效请清除缓存重新启动,多试几次就行
4、el-select 位置偏移以及 el-table tootip 位置偏移的问题
✨问题复现:
✨出现这种问题的原因:
el中依赖了poper.js 的fixed定位偏移,子应用的dom
挂载在shadowRoot
上,导致计算错误
官网解决办法
试了,发现没啥用,所以维护官网的大大请更新快点,要不然哪个还想用无界啊!!
✨最后解决办法:
使用插件 wujie-polyfill.github.io/doc/plugins…
// import { DocElementRectPlugin } from "wujie-polyfill";
<WujieVue
width="100%"
height="100%"
name="xxx"
:url="xxx"
:plugins="[DocElementRectPlugin()]"
></WujieVue>
我的element-plus 的版本:"element-plus": "^2.9.0",
这个版本比较新,如果你们使用的是比较老的版本或者使用的是element-ui的话,上面的插件可能不生效,可以看下面的解决方案
总结下来无非是几个办法,要么是改element-ui 中的源码,然后在项目中打补丁
要么直接在子应用加代码
body{position: relative !important}
.el-popper {position: absolute !important}
大家都可以都试一下,说不准哪个生效了
5、异步获取e.target 的 e.target 变成了 wujie-app
✨问题复现:
官网文档方法
上面尝试了不行
✨最后解决办法:
使用 插件 wujie-polyfill.github.io/doc/plugins…
import { EventTargetPlugin } from "wujie-polyfill";
// vue
<WujieVue
width="100%"
height="100%"
name="xxx"
:url="xxx"
:plugins=“[EventTargetPlugin()]”
></WujieVue>
完美解决
6、全局样式污染了子应用元素的样式
✨问题复现:
✨最后解决办法:
比如主项目中你给 html 或者 body 文字居中,在子项目中也会受影响,其实这个不算是框架的问题,因为你写了这个是全局样式,那就说明了这个会影响所有的,所以建议大家样式尽量写scope 并且对全局的样式尽量主项目和子项目同步,不要出现不一样的情况,要不然很难去排查这种问题
目前还没找到解决办法的问题:
1、自定义事件
很多项目集成了第三方sdk 比如埋点、错误监控、数据通信,其中sdk 可能会使用了js 的自定义事件,这个时候在子组件中会失效
✨问题复现:
const customEvent = new CustomEvent('update', {
bubbles: true,
composed: true,
detail: {
msg:'我的数据更新喽'
}
})
setTimeout(() => {
console.log(window.__WUJIE_RAW_WINDOW__,'window.__WUJIE_RAW_WINDOW__');
window.dispatchEvent(customEvent)
window.__WUJIE_RAW_WINDOW__ && window.__WUJIE_RAW_WINDOW__.dispatchEvent(customEvent)
}, 2000)
window.addEventListener('update', function(event) {
// 主应用没有反应,子组件中正常
console.log(event)
})
window.__WUJIE_RAW_WINDOW__ && window.__WUJIE_RAW_WINDOW__ .addEventListener('testEvent', function(event) {
// 主应用没有反应,子组件中正常
console.log(event)
})
会发现使用 window.addEventListener 或者 window.WUJIE_RAW_WINDOW .addEventListener 都没有用
✨出现这种问题的原因:
看了issue中作者说这个是无界中的bug ,所以如果有子组件用到这个自定义事件的,只能先将子组件用iframe 嵌入进去,等作者更新了再改回来
2、主应用路由切换和子应用路由切换点击浏览器退回没反应
✨问题复现:
官方示例:wujie-micro.github.io/demo-main-v…
大家也可以试一下,先点击主应用中的左侧vite 下的页面切换,然后再点击子应用中中间的页面切换,会发现需要点击两次浏览器的返回才能正常返回,可以看到我录屏下的点击返回前进和退回都没反应,只有多点一次才可以,目前还没找到好的解决办法,如果大家有办法解决可以告诉我。
结语
用了无界这么长时间给我的感觉还是比较好的,子应用保活非常丝滑,开箱即用,子应用基本上不需要更改任何代码直接可以继承到无界之中,这个优点真的是非常棒!不像qiankun 还得写很多适配的代码,当然qiankun 的社区真的比无界强大很多,很多问题你都能找到解决方案,只能说各有优缺点吧,主要看你自己怎么选择了
来源:juejin.cn/post/7444134659719610380
把鸽鸽放到地图上唱跳rap篮球需要几步?
事情的起因
最近在做地图类的创意应用没什么灵感,有一天晚上看到我弟弟玩游戏,发现机箱里有个ikun手办,这创作灵感就来了,鸽鸽+唱歌跳舞
有没有搞头?
说做就做,WebStorm
启动
1.初始化地图
这里的地图框架我用的是Mapmost SDK for WebGL,代码和效果如下
<script src="https://delivery.mapmost.com/cdn/sdk/webgl/v9.3.0/mapmost-webgl-min.js"></script>
<script>
let map = new mapmost.Map({
container: 'map',
style: 'https://www.mapmost.com/cdn/styles/sample_data.json',
center: [120.71330725753552, 31.29683781822105],
zoom: 16,
userId: '*******************', //填入你自的授权码
pitch: 60,
bearing: 75,
sky:"light",
env3D:{
exposure:0.3,
defaultLights: true,
envMap: "./yun.hdr",
}
});
</script>
2.设置地图样式
这里为了和模型本身的颜色契合,我隐藏了一些图层,然后把水系、道路、和陆地的颜色改了,代码和效果如下
//更改背景、水系、道路配色
map.setPaintProperty('bacground', 'background-color', 'rgb(159, 208, 137)')
map.setPaintProperty('ground_grass2', 'fill-color', 'rgb(103, 173, 144)')
map.setPaintProperty('water_big', 'fill-color', 'rgb(106, 190, 190)')
map.setPaintProperty('water_samll', 'fill-color', '#ADDCDF')
map.setPaintProperty('road_city_polygon-tex', 'fill-color', '#F1ECCC')
map.setPaintProperty('ground_playground', 'fill-color', '#FBD9E1')
//隐藏道路名
map.setLayoutProperty('road_metroline_line','visibility','none')
map.setLayoutProperty('road_metro_line', 'visibility', 'none')
map.setLayoutProperty('road_metroline_name', 'visibility', 'none')
map.setLayoutProperty('road_metro_name', 'visibility', 'none')
map.setLayoutProperty('road_city_name', 'visibility', 'none')
map.setLayoutProperty('road_country_name', 'visibility', 'none')
map.setLayoutProperty('road_others_name', 'visibility', 'none')
3.加载模型和图标
然后从网上下载了鸽鸽的obj模型
直接加载上去,这里作为模型图层添加,用法参考下面的文档:
http://www.mapmost.com/mapmost_doc…
//定义模型对象
let models_obj = [{
type: 'obj',
url: "./XHJ.obj",
mtl: "./XHJ.mtl",
}]
//配置模型图层参数
let options = {
id: 'model_id',
type: 'model',
models: models_obj,
center: [120.71482081366986, 31.299511106127838, 145],
callback: function (group, layer) {
}
};
//添加图层
map.addLayer(options);
鸽鸽就这样水灵灵的出现了
然后我们加几个图标上去,这里利用geojson数据,添加的自定义图层
//增加ikun图标
map.addSource("ikun", {
"type": "geojson",
"data": "./ikun.geojson"
})
map.loadImage('./111.png', (error, image) => {
if (error) {
throw error;
}
map.addImage('icon', image)
map.addLayer({
"id": "icon_ikun",
"type": "symbol",
"source": "ikun",
"layout": {
"icon-image": "icon",
"icon-size": 0.15,
"visibility": "visible"
}
})
})
好了,大功告成了
后续:如何实现唱跳rap篮球?
当然只看模型肯定不行,主要得让鸽鸽动起来,唱、跳、rap、篮球一样不能少
怎么办,MasterGO
启动, 急的我UI和交互都给一起做了,不会UI设计的开发不是好前端。
后面越想功能越多,干脆搞个小游戏算了,游戏名字我都想好了,叫:唤醒鸽鸽
,输入不同的口令,激活鸽鸽不一样的动作,伴随着地图一起舞动。
但是开发遇到了点困难,手机和模型材质的适配还在解决中....,另外模型骨骼动画有点僵硬,这个等我解决了再给大家分享,目前的效果还比较粗糙:
如果大家想到什么好玩的功能,也可以评论区讨论一下,不过一定要与地图结合才好玩。
关于代码:源码地址在这
基础场景的代码我先发给大家:
链接: pan.baidu.com/s/1G-r5qIXN… 提取码: y1p5
完整的代码等我解决掉bug了再分享
来源:juejin.cn/post/7449599345371283482
来自全韩国最好的程序员的 Nest.js 中 TypeSafe 方案
Nest.js 中的 TypeSafe 方案
在现代 Web 开发中,类型安全(TypeSafe)是提升代码质量和减少运行时错误的关键因素。
Nest.js 作为一个渐进式的 Node.js 框架,结合了 TypeScript 的强大功能,提供了构建高效、可扩展服务器端应用的理想平台。
笔者在使用 Nest.js 构建全栈应用时最大的痛点是写了这么多类型检查,好像没有办法和前端通用啊~。相信许多人也有这个问题,所以也冒出了在 Nest.js 中集成 tRPC 的教程。
而本文介绍的 Nestia,一个专为 Nest.js 设计的类型安全解决方案,帮助开发者在构建应用时实现更高的类型安全性和开发效率。
韩国最好的程序员
Jeongho Nam,GitHub用户名为 samchon,他在 README 中自称为韩国最好的程序员。他自1998年开始编程,拥有25年的丰富经验。在这段时间里,他开发了许多程序,并不断提升自己的技能。他不仅在工作中开发程序,还在业余时间开发开源项目,以满足自身需求或改进现有功能。这些开源项目逐渐形成了新的开源库,其中最著名的就是 typia 和 nestia。
什么是Nestia?
Nestia 是一个专为 Nest.js 开发的库,旨在通过利用 TypeScript 的类型系统,提供更高效和类型安全的开发体验。Nestia 的核心目标是简化数据传输对象(DTOs)的定义和验证,减少类型错误,并提升代码的可维护性。
Nestia的主要功能
- 类型安全的 DTO 定义和验证:
Nestia 利用 TypeScript 的类型系统,允许开发者定义类型安全的 DTOs。通过自动生成的类型定义,Nestia 确保了数据在传输和处理过程中的一致性,避免了常见的类型错误。
NestJS需要三个重复的 DTO 模式定义。第一个是定义 TypeScript 类型,第二个和第三个是调用
class-validator
和@nestjs/swagger
的装饰器函数。这不仅繁琐,而且容易出错。如果你在第 2 或第 3 处写错了的话,TypeScript 编译器是无法检测到的。只有在运行时才能检测到。换句话说,它并不是类型安全的。 - 自动生成API客户端:
Nestia 可以根据服务器端的 API 定义,自动生成类型安全的 API 客户端。这种方式不仅减少了手动编写客户端代码的工作量,还确保了前后端的一致性。
这一功能与著名的 tRPC 库有相似之处。tRPC 是一个端到端类型安全的 RPC 框架,它允许你轻松构建和使用完全类型安全的 API,无需模式或代码生成。tRPC 的主要作用是在全栈 TypeScript 项目中提供类型安全的 API 调用,大大提高了开发效率和代码质量。但 tRPC 的问题是通常要求前后端代码位于同一个 monorepo 中,以便共享类型定义。这种紧耦合的架构可能不适合所有项目,特别是那些前后端分离开发或需要为第三方提供 API 的场景。相比之下,Nestia 通过自动生成独立的 API 客户端解决了这个问题。它允许你在保持类型安全的同时,将生成的 SDK 作为独立的包分发给客户端开发者。这种方式既保留了类型安全的优势,又提供了更大的灵活性,使得 Nestia 在更广泛的项目结构和开发场景中都能发挥作用。
- 高效的JSON序列化和反序列化:
Nestia 提供了高效的 JSON 序列化和反序列化功能,利用 TypeScript 的类型信息,显著提升了性能和类型安全性。
如何使用Nestia
安装Nestia
你可以运行以下命令通过模版代码来快速上手 Nestia。
模板将自动构建在<directory>
中。作为参考,这是一个最小的模板项目,只集中于从 NestJS 服务器生成 SDK。它不包含数据库连接。
npx nestia start <directory>
你也可以运行下面的命令来将 Nestia 集成至现有的项目中。设置向导将自动安装并配置所有内容。
npx nestia setup
定义类型安全的DTO
你不需要掌握特殊的语法只使用 TypeScript 就可以编写一个带有类型检查的 DTO。当然,Nestia 也通过 Typia 提供了编写复杂类型检查的可能,例如,我们可以定义一个论坛完整的 DTO:
import { tags } from "typia";
export interface IBbsArticle {
/* Primary Key. */
id: string & tags.Format<"uuid">;
/* Title of the article. */
title: null | (string & tags.MinLength<5> & tags.MaxLength<100>);
/* Main content body of the article. */
body: string;
/* Creation time of article. */
created_at: string & tags.Format<"date-time">;
}
在 Controller
中调用 Nestia 的装饰器
NestJS 原生的装饰器(如 @Get()
, @Post()
等)虽然使用方便,但在性能和类型安全方面存在一些局限:
- 使用 class-validator 和 class-transformer 进行验证和转换,性能相对较低
- 需要定义额外的 DTO 类和装饰器,增加了代码量
- 类型安全性不够强,运行时可能出现类型错误
Nestia 的装饰器(如 @TypedRoute.Get()
, @TypedBody()
等)则解决了这些问题:
- 利用 typia 库进行高性能的运行时类型验证,比 class-validator 快 20,000 倍
- 支持使用纯 TypeScript 接口定义 DTO,无需额外的类定义
- 在编译时进行类型检查,提供更强的类型安全性
- JSON 序列化速度比 class-transformer 快 200 倍
import { TypedRoute } from "@nestia/core";
import { Controller } from "@nestjs/common";
import { IBbsArticle } from "./IBbsArticle";
@Controller("bbs/articles")
export class BbsArticlesController {
@TypedRoute.Get("random")
public async random(): Promise<IBbsArticle> {
return {
id: "2b5e21d8-0e44-4482-bd3e-4540dee7f3d6",
title: "Hello nestia users",
body: "Just use `TypedRoute.Get()` function like this",
created_at: "2023-04-23T12:04:54.168Z",
files: [],
};
}
@TypedRoute.Post()
public async store(
@TypedBody() input: IBbsArticle.IStore,
): Promise<IBbsArticle> {
return {
...input,
id: "2b5e21d8-0e44-4482-bd3e-4540dee7f3d6",
created_at: "2023-04-23T12:04:54.168Z",
};
}
}
自动生成API客户端
Nestia可以根据服务器端的API定义,自动生成类型安全的API客户端。在根目录配置 nestia.config.ts
文件
import { INestiaConfig } from '@nestia/sdk';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { AppModule } from './src/app.module';
const NESTIA_CONFIG: INestiaConfig = {
input: async () => {
const app = await NestFactory.create(AppModule, new FastifyAdapter());
app.setGlobalPrefix('api');
return app;
},
output: 'src/api',
clone: true,
distribute: 'sdk',
};
export default NESTIA_CONFIG;
运行命令 npx nestia sdk
即可生成 SDK package,你可以直接在 Monorepo 中使用它,也可以将其分发到 npm.
尝试使用生成的 SDK 看到类型提示的那一刻,整个人都通畅了~
结论
Nestia 作为一个专为 Nest.js 设计的类型安全解决方案,通过简化 DTO 定义和验证、自动生成 API 客户端以及高效的 JSON 序列化和反序列化功能,帮助开发者在构建应用时实现更高的类型安全性和开发效率。无论是大型企业级应用还是个人项目,Nestia 都是提升代码质量和开发效率的理想选择。
通过本文的介绍,希望您对 Nestia 有了更深入的了解,并能在您的 Nest.js 项目中尝试使用这一强大的工具,享受类型安全带来的诸多好处。
来源:juejin.cn/post/7385728319738216488
准备离开杭州
上个月的时候,我被公司裁掉了,陆陆续续找了 1 个月的工作,没有拿到 1 份 Offer,从网上看着各式各样的消息和自己的亲身体会,原来对于像我这样的普通打工族,找工作是如此的难。我相信,任何时候只要实力强,都能有满意的工作,但我不知道,能达到那样的水平还需要多久。
本人是前端,工作 6 年,期间经历过 4 家公司,前两份是外包,后面两份都是领大礼包走的,回想起来,职业生涯也是够惨的。虽然说惨,但是最近领的这一份大礼包个人认为还是值得,工作很难待下去,也没有任何成长,继续待着也是慢性死亡。
这几天我每天都会在 BOSS 上面投十几家公司,能回复非常少,邀请面试的就更少了。外包公司倒是挺多的,而我是从那个火坑里出来的,是不会选择再进去的。于是,我需要做好打持久战的准备,说不定不做程序员了。
我的房子 7 月底就要到期了,我必须要马上做决定,杭州的行情对我来说很不友好,短期内我大概率找不到工作。基于对未来的悲观考虑,我不想把过多的钱花费在房租上面,所以希望就近找一个三线城市,我搜了一下嘉兴,整租 95 平左右的房子只需要 1200 块钱,还是民用水电,思前想后,打算移居到那里嘉兴去。
一方面,我想尝试一下在三线城市生活是一种什么感觉。另一方面,这可以省钱,如果一个月的房租是 1000,民用水电,一个月的开销只要 2500 块。我搜索了一下货拉拉,从我的位置运到嘉兴,需要花费 600 块钱,这个价格也是可以接受的。思考了这些,我觉得是时候离开待了 5 年的杭州。
未来要到哪里去呢,目前可能的选择是上海。我还得想想未来能做什么,我想学一门手艺傍身,比如修理电器、炒菜。毕竟骑手行业太拥挤了,估计也不是长久之计。
房租降下来了,等我把行李都安置妥当,我打算回老家待一段时间。自从上大学以来,很少有长时间待在家里的时候,眼看父母年纪也越来越大了,很想多陪陪他们。如果进入正常的工作节奏,想做到这样还是会受到局限,这次也算是一个弥补的机会。
被裁也是一件好事,可以让我提前考虑一下未来的出路。
这段时间我想把时间用来专门学英语,再自己做几个项目,学英语的目的是为了 35 岁之后做打算,做项目是为了写到简历上面,并且个人觉得自己需要多做一个项目,这样自己才能成长到下一个级别。虽然不知道收益怎么样,但是我想尝试一下。人还活着,有精力,就还是瞎折腾一下。
离职没敢和家里说,说了估计要担心死了,反正是年轻人,有事就先自己扛一扛,我前几天把我的行李寄回去了一批,我妈问我,怎么,寄东西回来了?我回答说要搬家了。本来也想找机会开口说自己离职了,她说,这次搬家也别离公司远了,我也把话憋了进去,只好说“没事的,放心就行”。我自己没觉得离职有什么,正常的起起落落,只是觉得父母可能会过度的担心。
如果做最坏的打算,那就是回去种地,应该这几年还饿不死。有还没离职的同学,建议还是继续苟着。希望社会的低谷期早点过去,希望我们都能有美好的未来。
来源:juejin.cn/post/7395523104743178279
Taro v4框架开发微信小程序(配置)
环境变量文件
将 .env.dev
文件重命名为 .env.development
,以及将 .env.prod
文件重命名为 .env.production
,以适配环境配置。
为了演示如何使用环境变量,我们在 .env.development
文件中添加两个变量 TARO_APP_ID
和 TARO_APP_API
,然后在源代码中读取这些变量的值。
TARO_APP_ID="xxxxxxxxxxxxxxxxxx"
TARO_APP_API="https://api.tarojs.com"
接下来需要在 project.config.json
文件中更新 appid
的值。因为上一章节中为了测试修改了这个值,现在我们需要把它改回原来的 appid
:
"appid": "touristappid",
在完成以上操作后,重新启动项目(使用命令 pnpm dev:weapp
),控制台会显示相关的提示信息,并且可以看到 dist/project.config.json
文件中的 appid
已经变成了我们在 .env.development
文件中指定的 TARO_APP_ID
值。
为了在代码中使用环境变量,可以在 src/pages/index/index.tsx
文件的 useLoad
钩子中添加 console.log
语句来打印 TARO_APP_API
的值:
console.log(process.env.TARO_APP_API)
这样做的结果是,当程序运行时,可以在微信开发者工具的控制台中看到 TARO_APP_API
环境变量的值被成功打印出来。
这里需要记得将环境变量的appid改为你正常使用的appid,否则小程序会报错。
之后运行程序,并在微信开发者工具中浏览:
需要注意的是,只有以 TARO_APP_
开头的环境变量才会被 webpack 的 DefinePlugin
插件静态嵌入到客户端代码中。这是为了避免环境变量与系统内置变量冲突。在构建过程中,代码中的 process.env.TARO_APP_API
会被替换为实际的环境变量值。例如,我们在小程序开发者工具中查看编译后的代码,会看到 console.log(process.env.TARO_APP_API)
被替换成了 console.log("https://api.tarojs.com");
。
编译配置
编译配置是 Taro 项目开发过程中重要的一部分,它决定了项目的编译行为。Taro 的编译配置主要存放在项目根目录下的 config
文件夹内,由 index.ts
文件统一导出。其中,index.ts
通过合并 dev.ts
和 prod.ts
来分别处理开发时的配置和构建时的生产配置。dev.js
适用于项目预览时的设置,而 prod.js
则适用于项目打包时的设置。
在 Taro 的编译配置中,可以设置项目名称、创建日期、设计稿尺寸、源码目录等基本配置信息。下面的代码片段展示了一部分编译配置的内容:
const config = {
// 项目名称
projectName: 'learn-taro-wxapp',
// 项目创建日期
date: '2024-3-11',
// 设计稿尺寸
designWidth: 750,
// 设计稿尺寸换算规则
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
// 项目源码目录
sourceRoot: 'src',
// 项目产出目录
outputRoot: 'dist',
// Taro 插件配置
plugins: [],
// 全局变量设置
defineConstants: {},
// 文件 copy 配置
copy: {
patterns: [],
options: {},
},
// 框架,react,nerv,vue, vue3 等
framework: 'react',
// 使用 webpack5 编译
compiler: 'webpack5',
cache: {
enable: false // Webpack 持久化缓存配置,建议开启
},
// 小程序端专用配置
mini: {
postcss: {
pxtransform: {
enable: true,
config: {
}
},
autoprefixer: {
enable: true,
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
// 自定义 Webpack 配置
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin)
}
},
// H5 端专用配置
h5: {
publicPath: '/',
staticDirectory: 'static',
postcss: {
autoprefixer: {
enable: true,
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
// 自定义 Webpack 配置
webpackChain(chain, webpack) {},
devServer: {},
},
rn: {
appName: 'taroDemo',
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
}
}
}
}
module.exports = function (merge) {
if (process.env.NODE_ENV === 'development') {
return merge({}, config, require('./dev'))
}
return merge({}, config, require('./prod'))
}
在编译配置文件中,alias
被用来设置路径别名,避免在代码中书写过多的相对路径。在配置文件中,默认已经将 @
设置为指向 src
目录,这样,在代码中就可以使用 @
快捷引用 src
下的文件了。
我们还可以增加额外的配置,例如:
alias: {
'@/components': path.resolve(__dirname, '..', 'src/components'),
}
使用 defineConstants
可以定义全局常量,例如,可以基于不同的环境设置不同的全局变量。
defineConstants: {
__DEV__: JSON.stringify(process.env.NODE_ENV === 'development'),
__PROD__: JSON.stringify(process.env.NODE_ENV === 'production')
}
等等...
如果想要查阅每个配置项的具体意义和用法,可以按住 Ctrl + 鼠标左键
点击属性名,跳转到 project.d.ts
类型声明文件中查看对应注释和示例代码。
designWidth
用于指定设计稿的宽度,这里设置的是 750px
,这意味着使用的 UI 设计稿的宽度标准是 750px
。Taro 提供了多个设计稿尺寸的换算规则,当前项目中已经设置了几种不同尺寸对应的换算比例。如下所示:
// 设计稿尺寸换算规则
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
},
对于 UI 设计师而言,推荐使用 750px
作为设计尺寸标准,它便于开发者使用 Taro 进行开发时,进行适配和转换。
对于更详细的编译配置,可以查询官方文档中的编译配置详情。
app.config.ts 通用配置文件
在 Taro 框架中,app.config.ts
是小程序的通用配置文件,其主要职责是定义小程序的页面及其全局属性。以下是针对 app.config.ts
文件中一些关键配置项的说明:
pages
pages
数组用于指明组成小程序的页面列表,每个元素都指向一个页面的路径加文件名,无需包含文件扩展名。由于 Taro 会自动处理寻找文件,这会带来方便。改变小程序页面结构时,如新增或删除页面,都应相应地更新 pages
配置。
pages: [
'pages/index/index',
'pages/other/other',
// ... 其他页面路径
]
其中,数组中的第一个条目表示小程序的入口页面(首页)。
window
window
对象用于设置小程序的状态栏、导航条、标题和窗口背景色等界面表现。
window: {
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#eeeeee',
// ... 其他窗口属性
}
查看属性详细信息和支持程度,你可以通过按住 Ctrl + 鼠标左键
点击任意属性,跳转至 taro.config.d.ts
类型声明文件。支持程度不同的平台详细请查阅官方文档。
tabBar
对于包含多个 tab(在客户端窗口底部或顶部有 tab 栏切换页面的)的小程序,tabBar
配置用于定义 tab 栏的外观以及每个 tab 对应的页面。
tabBar: {
color: "#434343",
selectedColor: "#333333",
// ... tabBar 属性和 tab 列表
}
tabBar
中的 list
属性是一个包含若干对象的数组,每个对象定义了一个 tab 项的页面路径、图标和文字等。点击 tab
时,应用会切换到对应的页面。
关于 tabBar
的更多详细配置项,也可以通过点击属性,跳转至 TypeScript 的类型声明文件中查看功能描述。
支持程度不同的平台详细请查阅官方文档
页面的config.ts配置文件
单个页面也可以有自己的配置文件,通常是 config.ts
。页面配置会被 definePageConfig
函数包裹,并作为参数传入,其参数类型是 Taro.PageConfig
。PageConfig
继承自 CommonConfig
类型。
export default definePageConfig({
navigationBarTitleText: '页面标题'
})
project.config.json 微信小程序配置文件
除了 Taro 的配置外,微信小程序也需一个 project.config.json
,这个配置文件包含了微信小程序的专有配置。关于此配置你可以参考微信小程序官方文档。
来源:juejin.cn/post/7345063548705718312
8年老前端开发,成仙路已断
前言
到今年,我已经写了8年代码。在这段时间里,我的职业生涯一直处于不上不下的状态。尽管在二线城市的薪资看起来还不错,但我早就明白,这行不过是用青春换取血汗钱。从疫情开始,我内心变得愈发焦躁不安,尤其是今年,心态更加低迷。无论是职场上的无尽业务,还是长期的工作倦怠,都在促使我做出改变的决定。
最终,在三月底,我主动离职了(没错,在最艰难的时刻做出了这个决定)。从那时起,这三个多月来,我一直在思考和寻求变化。转眼间,已经到了七月底。虽然这段时间收入不如上班时稳定,但我的状态却越来越好。
简单回顾下个人开发经历
本人是双非辣鸡学校毕业。
2016年 初开始入行前端开发,那时候还处在前后端不分离的时代,开始主要是写 JSP,主要以原生JS和JQ为主,那时候前端主要是司职辅助功能,事情也比较少,相对比较轻松。
2017年 开始前后端分离大行其道,也开始用“所谓”的现代化前端框架,那时候主要是用react全家桶,当时觉得,卧槽,这TM比一个个操作dom节点确实方便不少,最关键的是webpack之流的工具让前端开发更加成体系,不像以前是寄生在jsp里面了,工程化更明显了,当时,前端真正独立成了一个岗位,不再是纯辅助,而是研发流程的重要一环。
2018年 一整年都在用RN开发APP,第一次接触移动端开发,RN是我一生的噩梦,以至于后面我看到跨平台的东西做复杂应用就谨慎的很,当时还是0.5x的时代,各种安卓、ios端的BUG。我记得RN官网写的slogan一次编写,多端运行
,后面可能是投诉太多了,官网后面好像改成还是需要原生安卓/ios知识了。我记得那时候做一个IM应用,长列表白屏,realm数据库大量消息写入卡顿,各种原生不兼容,我都不记得晚上花了多少时间在github的issue上找兼容方案和各种hack了。再加上当时五大厂商各种魔改安卓,华为刚刚切换鸿蒙系统(就这么巧),无尽的debuff(现在想想当初直接学原生梭哈更好,泪水不争气的落下)
2019年 开始用vue开发web,比起react,更多的语法糖,当时觉得slot插槽这种封组件的方法确实要更方便。后面接私单果断vue梭哈,做公司产品还是react,毕竟没有slot给队友乱用
2020年-至今 从疫情开始整个职业规划紊乱。一开始为了更具竞争力,开始往全栈方向发展,努力自学node.js/koa ,开始用公司的几个小型产品实践,当时一度以为自己全栈,后面才知道我这叫全不占工程师。后面又因为公司业务需求,又开始用electron开发桌面应用,用uniapp开发多端小程序,用python套壳gpt的api(有了后面的经历,我才知道不是巨复杂的应用跨平台的坑也还行),并且机缘巧合之下,还有了机会用laya引擎开发微信小游戏。直至最后,彻底成为全不栈工程师,纯纯牛马。
总结职业生涯 没什么规划,跟着公司的业务随波逐流,属于先知后觉,觉醒了又不够决心,总是想着现在行情不好,实在不行再攒一点钱
,然后就一直蹉跎到了2024年,一事无成。
我的反思
既然已经这样了,只能自救了,不然咋办呢哈哈哈哈
1、定位问题
- 离开了公司无法生存,没法形成独立生产单元
- 学历、年纪、技术都不占优势
- 行业环境急剧恶化,现在长沙公司基本都是大小周或者单休,属于买方市场,并且加班严重
2、分析原因
说白了,核心原因就几个:
- 程序员不能形成独立生产单元,寄生于公司,也没有机会了解整个商品从规划到收款的全生命周期
- 可替代性高,开源生态太强,且总有更厉害的人,总有学历更好的人,怎么卷最终都会失败
- 自身可出售的商品只有
时间
一项,缺少其他收入
3、解决方案
3.1、最优解
开发独立产品
,这是每一个程序员的梦想,却也是最难得路,属于是下限低,上限高。卖课
,也就是所谓的知识付费,现在卖课的平台很多,但是需要人设、背景和标签,还需要运营推广,属于极稳的做法。找个一辈子的养老公司
,不是没有,只是不好进,我上次还看见图书馆和纪念馆招人,只是有编制很难。
3.2、临时解
接外包
,我现在也在接,个中滋味,只能说懂的都懂。找个班上
,如果实在缺钱或者不知道做什么,也可以先干着。技术顾问
,不同于外包,就是靠着一点经验解决点冷门问题。
3.3、风险解
开店
,重投入,不成功便成仁,需要梭哈。出海
,去日本、东南亚、甚至是更远的地方,写代码也好,干其他的也好,需要勇气。开公司
,只要有业务,就能苟活,问题是怎么有业务,需要资源。
4、我的解题思路
做自己的独立产品
,以尝试的心态,,将自己内心的idea实现,切勿抱着什么多少爆款的思路,多学习别的独立开发者好的思路和见解。做了就上线,反复试错。关于上班
,现在很少有什么新公司,新产品出现,都是旧项目在维护,成仙路已断,注定走不了了。现在基本只接外包单子,上班继续做web是不可能了。关于小游戏开发
,参照发达国家的经验,可以一直做下去,创意类很难被替代。并且不像web,这行业的经验不怎么共享,开源代码也少,这是良性生态,由于岗位少,对于职场不友好,但是对于个人开发很不错。做了就上线,反复试错。关于转行
,盲目转行不可取,大环境下都是大哥笑二哥。关于技术自媒体
,不给自己设限,也可以去做做。
我这3个月做了什么
今年4月
迷茫了半个月,然后躺平了半个月,真的好开心。
今年5月
上半个月学习开传奇私服,然后去自驾去青海湖玩了半个月,真的好开心。
今年6月
- 做了一个AI做菜小程序,套壳子那种,微信不给上架,说个人不能做信息咨询,放弃。
- 写了一个模拟用户操作爬取数据的桌面工具,后面可能有用。
- 不信邪,做了一个躺平记账的小程序,刚刚上架,是人都要做一次三件套。
- 用laya和cocos做了几个入门小游戏,并开源,给想web转游戏的小伙伴们降低一点曲线,因为游戏引擎的文档不是一般的烂(我原以为小程序的文档很差,直到我看到了几个游戏引擎的文档),大部分时间一边看源码一边开发。
今年7月
- 做了10+传奇私服,并且赚了几百元(聊胜于无),不敢做大,不敢宣传,容易被封印。
- 做了一个创意肉鸽小游戏(
有没有美术搭子,全靠AI生图走的很艰难
),在到处寻找美术资源,代码已写完(等替换完资源就上)。
我的规划
2024年度规划
- 继续做肉鸽、塔防类游戏
- 继续开私服
- 学习新的私服技术(暂定DNF)
- 继续做自己的独立产品
远景规划
- 追随自己的内心,不给自己设限
- 动态寻找可长期耕耘的赛道
写在最后
多年以后,我总是想起2016年刚入行时,只用写写js就能拿薪水的那个下午。
来源:juejin.cn/post/7393312386348138530
站住!送你一个漂亮的毛玻璃渐变背景
大家好呀,我是 HOHO。
不知道你之前有没有接触过这种需求,实现一个下面这样的背景:
一层毛玻璃,然后后面有一些渐变的色块动来动去。这种效果大家应该都比较熟悉,之前有段时间 Apple 很热衷于使用类似的特效,说实话确实挺好看。我们一般管这种效果叫做 blurry gradient、或者模糊渐变、磨砂渐变、毛玻璃渐变。
本来以为一个背景而已,有什么难度,没成想一路走来还真踩到了不少的坑。本着我摔了一身泥不能让大家再摔一次的原则。我把这种效果封装成了一个 React 组件 react-blurry-gradient,大家可以直接拿来用,省的再抄代码浪费脑细胞。
在讲组件之前,我先来介绍一下都踩了哪些坑,如果你急用的话,前面直接无视就行。OK 话不多说,我们现在开始。
心路历程
1、shader gradient
其实一开始和 UI 沟通的时候,这个效果并不是典型的模糊渐变。而是一个 Shader,你可以在 这个网站 看到类似的效果,这个 Shader Gradient 还有对应的 Figma 插件。
这个效果其实实现起来不难,因为它提供了对应的 react 插件 ruucm/shadergradient: Create beautiful moving gradients on Framer, Figma and React。只需要把 UI 给的参数填进去就可以了。
但是事情并没有想象的那么简单,单纯的复刻效果倒没什么问题,问题出在这玩意居然自带一个十分离谱的入场特效:
可以看到,这个效果初始化的时候会有个旋转缩放的“入场动画”,让人忍俊不禁。不仅如此,这个背景还非常离谱的是可以拖动和缩放的:
这两个问题在组件的文档里并没有任何说明,我猜测这个效果组件是基于 threejs 实现的,出现这两个问题应该是 threejs 的一些默认设置没有关闭导致的。
不过这些也不是什么大问题,我们可以通过控制入场透明度和添加蒙层来解决。真正阻止我继续使用的是性能问题。因为这个项目要支持 H5 端,而老板的破水果手机打开这个效果都要燃起来了。没办法只能作罢。
2、css-doodle
听说这个效果差点让老板换手机之后,UI 挠了挠头,说要不我给你点颜色,你干脆写个毛玻璃渐变得了。我觉得他说的有道理,随手一搜,这不就来了:妙用滤镜构建高级感拉满的磨砂玻璃渐变背景。
css-doodle 我之前研究过,虽然不太喜欢这种写法风格,但是谁能拒绝直接抄的代码呢?三下五除二就搞成了 React 版本:
import 'css-doodle';
import styles from './index.module.less';
const DOODLE_RULES = `
:doodle {
@grid: 1x8 / 100vmin;
width: 100vw;
height: 100vh;
}
@place-cell: center;
width: @rand(40vw, 80vw);
height: @rand(40vh, 80vh);
transform: translate(@rand(-50%, 50%), @rand(-60%, 60%)) scale(@rand(.8, 1.8)) skew(@rand(45deg));
clip-path: polygon(
@r(0, 30%) @r(0, 50%),
@r(30%, 60%) @r(0%, 30%),
@r(60%, 100%) @r(0%, 50%),
@r(60%, 100%) @r(50%, 100%),
@r(30%, 60%) @r(60%, 100%),
@r(0, 30%) @r(60%, 100%)
);
background: @pick(#FBF1F7, #B27CEE, #E280AE, #c469ee, #a443ee, #e261bb, #e488ee);
opacity: @rand(.3, .8);
position: relative;
top: @rand(-80%, 80%);
left: @rand(-80%, 80%);
animation: pos-change @rand(4.1s, 10.1s) infinite 0s linear alternate;
@keyframes pos-change {
100% {
left: 0;
top: 0;
transform: translate(@rand(-50%, 50%), @rand(-60%, 60%)) scale(@rand(.8, 1.8)) skew(@rand(45deg))
}
}`;
export const Bg = () => {
return (
<div className={styles.loginBg}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<css-doodle>{DOODLE_RULES}</css-doodle>
</div>
);
};
index.module.less
.loginBg {
position: absolute;
margin: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #fff;
z-index: -1;
&::after {
content: '';
position: absolute;
top: -200%;
left: -200%;
right: -200%;
bottom: -200%;
backdrop-filter: blur(200px);
z-index: 1;
}
}
windows 上打开正常,但是 Safari 打开之后发现毛玻璃效果直接消失了,就像下面这样:
这给我整不会了,按理说 Safari 是支持 backdrop-filter
的。是 css-doodle 在 safari 上有什么兼容性问题?还是 react 和 css-doodle 的集成上出了什么毛病?我没深入了解,再加上本来不小的包体积,于是 css-doodle 方案也被我放弃了。
看看新组件 react-blurry-gradient
OK,踩了一圈子坑,下面该请出本文的主角 react-blurry-gradient 了,我们直接看效果:
如果 GIF 比较模糊的话可以试一下这个 codesandbox 在线 demo。
用法也很简单,安装,然后引入组件和对应的 css 文件即可:
npm install react-blurry-gradient
import { BlurryGradient } from 'react-blurry-gradient';
import 'react-blurry-gradient/style.css';
const colors = ['#bfdbfe', '#60a5fa', '#2563eb', '#c7d2fe', '#818cf8', '#4f46e5'];
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<BlurryGradient colors={colors} />
</div>
);
}
组件会自动的从你指定的颜色列表中随机挑选颜色来生成渐变和动效。
如果你颜色也不想找,没问题,组件还内置了一套渐变颜色组,直接用就行了(目前包含红黄蓝绿紫五套):
import { BlurryGradient, COLORS } from 'react-blurry-gradient';
import 'react-blurry-gradient/style.css';
export default function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<BlurryGradient colors={COLORS.BLUE} />
</div>
);
}
这些颜色就是单纯的字符串数组,所以你可以把这些颜色兑在一起来实现撞色的效果:
<BlurryGradient colors={[...COLORS.BLUE, ...COLORS.RED]} />
如果你想设置背景色,没问题,react-blurry-gradient
本身就是透明的,也就是说你背景用什么都可以,直接设置给父元素就行了:
export default function App() {
return (
<div style={{ backgroundColor: COLORS.BLUE[0], width: '100vw', height: '100vh' }}>
<BlurryGradient colors={COLORS.BLUE} />
</div>
);
}
预设的 COLORS 第一个元素都是颜色最淡的,你可以直接拿来当作背景。
把这个背景色设置给 BlurryGradient
组件的 style 也可以,不过这样会在边缘形成一圈扩散的效果,而设置给父元素的话背景色就会更均一。
另外毛玻璃的模糊效果也是可以调整的,只需要设置 blur
参数即可,比如你可以调低一点,我觉得也挺好看的:
<BlurryGradient colors={[...COLORS.BLUE, ...COLORS.RED]} blur='20px' />
除此之外还有很多额外参数,例如可以通过 itemNumber
来控制生成色块的数量。可以通过 itemTop
、itemLeft
来控制色块随机生成的位置范围。更多的属性可以在 react-blurry-gradient 配置参数 找到。
如果你不希望随机生成,想精确控制每个色块的颜色、尺寸、位置和运动轨迹,没问题,BlurryGradient
组件的 items
参数允许你指定每个色块的详细配置。参数说明可以看 这里。但是要注意,启用了 items
之后,colors
的配置就会被忽视了。
零依赖 & 极小体积
这个组件从一开始就以够轻量为宗旨,所以你可以看到它没有依赖任何第三方包,只要你项目里有 React,那它就能用:
而包本身也足够的清爽,不会往你电脑里拉屎:
组件所有的代码加起来只有 4Kb 不到。
当然,如果你真的不想因为这种小事再引一个包,没问题,都帮你收拾好了,把 这个文件夹 直接复制到你的项目里就能用,给个 star 意思一下就行~
来源:juejin.cn/post/7446018863504506907
都快2025年了,你们的前端代码都上装饰器了没?
可能很多人都听说过 TypeScript 的装饰器,也可能很多人已经在很多 NestJS 项目中使用装饰器了,也有一些前端开发者可能在某些前端框架中使用过一些简单的装饰器,那么你真的知道装饰器在前端还能玩出哪些花吗?
我们今天不讲基础概念,也不写一些你可能在很多文章里都看到过的没有意义的示例代码,我们直接拿装饰器来实战实现一些需求:
一、类装饰器
虽然很多前端对于类和面向对象是排斥的、抵触的,但不影响我们这篇文章继续来基于面向对象通过装饰器玩一些事情。
我已经写了很多关于面向对象在前端的使用了,实在是不想在这个问题上继续扯了,可以参考本专栏内的其他文章。
虽然但是,不论如何,你可以不用,但你不能不会,更不能不学。
不管在前端还是后端,我们可能都会用到类的实例来做一些事情,比如声明一个用户的类,让用户的类来完成一些事情。
我们可能会为类配置名称,比如给 User
类定义为 用户
:
// 声明一个装饰器,用来保存类的文案
function Label(label: string) {
return (target: any) => {
Reflect.set(target, "label", label)
}
}
@Label("用户")
class User {
}
我们不限制被标记的类,你可以把any 用泛型约束一下,限制这个装饰器可以标记到哪些类的子类上。
我们可以通过 Reflect
来获取到类上的元数据,比如 Label
这个类上的 name
属性,通过 Reflect.getMetadata('name', User)
来获取到:
// 将打印 "用户"
console.log(Reflect.get(User, "label"))
通过这种方式,我们可以为类标记很多配置,然后在使用的时候就不会在代码里再出现很多类似 “用户” 的魔法值了。如果有改动的话,也只需要将 @Label("用户")
改成 @Label("XXX")
就好了。
当然,事实上我们不会单独为了一个小功能去声明一个装饰器,那样到时候会给类上标记很多的 @
看着难受,于是我们可以直接声明一个 ClassConfig
的装饰器,用来保存类的各种配置:
interface IClassConfig {
// 刚才的 label
label?: string
// 添加一些其他的配置
// 表格的空数据文案
tableEmptyText?: string
// 表格删除提醒文案
tableDeleteTips?: string
}
function ClassConfig(config: IClassConfig){
return (target: any) => {
Reflect.set(target, "config", config)
}
}
@ClassConfig({
label: "用户",
tableEmptyText: "用户们都跑光啦",
tableDeleteTips: "你确定要删除这个牛逼的人物吗?"
})
当然,我们可以通过 Reflect.getMetadata('config', User)
来获取到 ClassConfig
这个类上的配置,然后就可以在代码里使用这些配置了.
比如,我们还封装了一个 Table 组件,我们就只需要将
User
这个类传过去,表格就自动知道没数据的时候应该显示什么文案了:
<Table :model="User" :list="list" />
上面的表格内部,可以获取 model
传入的类,再通过 Reflect
来获取到这些配置进行使用,如果没有配置装饰器或者装饰器没有传入这个参数,那么就使用默认值。
二、属性装饰器
很多人都知道,装饰器不仅仅可以配置到类上,属性上的装饰器用处更多。
这个和上面第一点中的一样,也可以为属性做一些配置,比如给用户的账号属性做配置,而且我们还可以根据主要功能来声明不同的装饰器,比如表单的 @Form
,表格的 @Table
等等。
class User {
@Field({
label: "账号",
// 如果下面的没有配置,那我来兜底。
isEmail: true,
})
@Form({
// 表单验证的时候必须是邮箱
isEmail: true,
// 表单验证的时候不能为空
isRequired: true,
placeholder: "请输入你牛逼的邮箱账号"
})
@Table({
// 表示表格列的邮箱点击后会打开邮件 App
isEmail: true,
// 表格列的宽度
width: 200,
// 需要脱敏显示
isMask: true
})
account!: string
}
当然,属性的装饰器声明和类的声明方式不太一致:
interface IFieldConfig {
label?: string
isEmail?: boolean
}
function Field(config: any) {
return (target: any, propertyKey: string) => {
Reflect.set(target, propertyKey, config)
}
}
使用 Reflect
获取的时候也不太一致:
const fieldConfig = Reflect.get(User.prototype, "account")
// 将打印出 `@Field` 配置的属性对象
console.log(fieldConfig)
想象一下,你封装的表格我也这么使用,我虽然没有传入有哪些表格列,但你是不是能够通过属性是否标记了 @Table
装饰器来判断是否需要显示这个邮箱列呢?
<Table :model="User" :list="list" />
你也可以再封装一些其他的组件,比如表单,比如搜索等等等等,像这样:
<Input :model="User" :field="account" />
上面的 Input 组件就会自动读取 User
这个类上的 account
属性的配置,然后根据配置来渲染表单和验证表单,是不是美滋滋?
三、方法装饰器和参数装饰器
这两个方式的装饰器我们在这篇文章不细讲,等装饰器这玩意在前端能被大家接受,或者前端娱乐圈骂面向对象不再那么狠的时候再细化一下吧,今天我们只讲讲简单使用:
3.1 方法装饰器
说到方法装饰器,我想先提一嘴 AOP
编程范式。
AOP(Aspect Oriented Programming) 是一种编程范式,它把应用程序切面化,即把应用程序的各个部分封装成可重用的模块,然后通过组合这些模块来构建应用程序。
举个简单的例子,我们最开始写好了很多代码和方法:
class User {
add(name: string) {
console.log("user " + name + " added!")
}
delete(name: string) {
console.log("user " + id + " deleted!")
}
}
const user = new User();
user.add("Hamm")
user.delete("Hamm")
以前调用这些方法都是正常的,突然有一天需求变了,只允许超级管理员才能调用这两个方法,你可能会这么写:
class User {
add(name: string) {
checkAdminPermission()
console.log("user " + name + " added!")
}
// 其他方法
}
function checkAdminPermission() {
if(!你的条件){
throw new Error("没有权限")
}
}
const user = new User();
user.add("Hamm")
虽然也没毛病,但需要去方法内部添加代码,这属于改动了已有的逻辑。
而 AOP 存在的意义,就是通过切面来修改已有的代码,比如在方法执行前,执行一段代码,在方法执行后,执行一段代码,在方法执行出错时,执行一段代码,等等。用更小的粒度来减少对已有代码的入侵。像这样:
class User {
@AdminRequired
add(name: string) {
console.log("user " + name + " added!")
}
}
function AdminRequired(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
descriptor.value = function (...args: any[]) {
if (你的条件) {
return originalMethod.apply(this, args)
}
throw new Error("没有权限")
}
}
const user = new User()
console.log(user.add("Hamm"))
乍一看,我就知道又会有人说:“你这代码不是更多了么?” 看起来好像是。
但事实上,从代码架构上来说,这没有对原有的代码做任何改动,只是通过 AOP 的方式,在原有代码的基础上,添加了一些前置方法处理,所以看起来好像多了。但当我再加上一些后置的方法处理的话,代码量并没有添加多少,但结构会更清晰,代码入侵也没有。
传统写法(入侵)
class Test{
张三的方法(){
// 李四的前置代码
// 张三巴拉巴拉写好的代码
// 李四的后置代码
}
}
张三:“李四,你为什么用你的代码包围了我的代码!”
装饰器写法(不入侵)
class Test {
@LiSiWantToDoSomething
张三的方法() {
// 张三巴拉巴拉写好的代码
}
}
function LiSiWantToDoSomething(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value
descriptor.value = function (...args: any[]) {
console.log("李四的前置代码")
const result = originalMethod.apply(this, args)
console.log("张三干完了,结果是" + result)
return "我是李四,张三的结果被我偷走了"
}
}
这时,张三的代码完全在不改动的情况下添加了前置和后置代码。
3.2 参数装饰器
参数装饰器的使用场景在前端比较少,在 Nest 中比较多,这篇文章就不过多介绍了,如果后续大伙有兴趣我们再聊。
四、总结
装饰器是一种新的语法,可以让你的前端代码更加的架构化,增加代码的可维护性。
如果你有兴趣,还可以阅读本专栏内的这些文章:
TypeScript中如何用装饰器替代JSON配置项封装表单
当然,其他文章也很有意思哟~
今天就这样,欢迎继续关注我们的专栏 《用TypeScript写前端》
也欢迎关注我们的开源项目: AirPower4T,里面有很多装饰器在前端的应用场景,也许可以让你耳目一新。
Bye.
来源:juejin.cn/post/7449313175920459811
不要再二次封装 axios 了,它真的是“灵丹妙药”吗?
引言
最近,看到不少开发者在讨论如何“优雅地”封装 axios 时,我的内心不禁发出一声叹息——“收手吧阿祖,别再封装了!”我们都知道,axios 是一个轻量级的 http 客户端库,广泛用于前端和 node.js 环境中,因其简洁易用、功能丰富而备受喜爱。但问题来了:为什么那么多人非要二次封装它?是想追求什么“优雅”代码,还是只是满足某种程序员的“封装癖”?在我看来,二次封装 axios 的行为,其实更多的是“低效”和“麻烦”!
在这篇文章中,我将分析二次封装 axios 的现象,揭示它的弊端,并提出更合理的解决方案。
二次封装 axios 背后的动机
首先,得承认,在许多开发者的心目中,二次封装 axios 是“提升代码复用性”、“提升可维护性”的一种手段。究竟是什么驱动他们这么做呢?让我们来看看,通常的封装动机有哪些。
- 全局配置管理
很多人为了避免在每个请求中都写重复的配置(如 baseURL、timeout、headers 等),于是将 axios 封装成一个单独的模块,统一管理。这样一来,代码看似简洁了。 - 请求/响应拦截器
除了常见的全局配置外,二次封装通常还会加入请求和响应拦截器,用于做统一的错误处理、日志记录、token 刷新等。这听起来很有吸引力,似乎让项目更加“健壮”了。 - 封装错误处理
统一处理 HTTP 错误,诸如 400、500 错误等,这让开发者避免了在每个请求中重复编写错误处理逻辑。 - 功能扩展
比如:增加一些额外的功能,比如请求重试、自动刷新 token、请求取消等。
这些动机听起来有理有据,似乎是为了减少重复代码、提高效率。但,二次封装真的是解决问题的最佳方法吗?
二次封装 axios 的弊端:看似优雅,实际繁琐
虽然二次封装看起来很“高级”,但它带来的问题也是显而易见的。
1. 失去灵活性,降低可维护性
当我们通过二次封装 axios 将所有请求逻辑集中到一个地方时,代码复用的确得到了提高,但灵活性却大大下降。每当我们需要调整请求方式、处理特殊错误、或者添加新的请求功能时,必须在封装层修改代码,这意味着对每一个新请求的修改都变得更加复杂,导致代码膨胀,维护成本上升。
举个例子,假设你有一个简单的请求需要添加一个额外的请求头或参数,但你的封装类已经把一切都“包裹”得很严实,你不得不进入封装类的内部进行修改。这种情况下,封装的意义反而变得虚假。
2. 过度封装,增加不必要的复杂性
封装本应是为了简化代码,但过度封装反而让事情变得更加复杂。例如,很多二次封装的 axios 都包含了一堆的“自定义配置”,导致请求时你不得不先了解封装类的具体实现,甚至可能在不同项目之间迁移时也要重新学习一套封装规范。每次需要调用 api 的时候,都要与一个封装层打交道,这显然不是开发者想要的高效体验。
3. 性能问题:拦截器是双刃剑
请求和响应拦截器的设计初衷无疑是为了统一处理请求逻辑,但过多的拦截器往往会导致性能瓶颈。特别是在大型项目中,拦截器的链式执行可能带来额外的延迟。此外,拦截器中常常会加入错误处理、token 刷新等额外逻辑,这会影响整个请求链的执行效率。
4. 可能引发版本兼容性问题
随着项目的不断迭代,封装的代码与 axios 原生的更新频繁不一致,导致二次封装的代码容易发生维护上的“断层”或兼容性问题。每当 axios 更新时,可能需要你手动修复封装类中的依赖,甚至重构整个封装层,造成额外的开发工作量。
为什么我们不需要二次封装 axios?
那么,既然二次封装带来了这么多麻烦,我们应该如何解决 axios 使用中的痛点呢?
1. 使用 axios 的内置功能
axios 本身就有非常强大的功能,很多二次封装中提到的配置(比如 baseURL、headers 等)都可以直接通过 axios 的实例化来轻松解决。例如:
const axiosInstance = axios.create({
baseURL: 'https://api.example.com',
timeout: 1000,
headers: {'X-Custom-Header': 'foobar'}
});
这样,所有请求都可以通过统一的实例管理,无需复杂的封装,且灵活性保持不变。
2. 合理使用拦截器
axios 的请求和响应拦截器非常强大,用得好可以让你的代码更简洁。错误处理、token 刷新、请求取消等功能,都可以直接在 axios 拦截器中完成,而不需要一个额外的封装类。
axios.interceptors.response.use(
response => response,
error => {
if (error.response.status === 401) {
// Token 刷新逻辑
}
return Promise.reject(error);
}
);
通过这种方式,我们在全局进行处理,而不需要一层一层的封装,让代码保持简洁并且具有良好的扩展性。
3. 利用第三方库增强功能
如果你确实需要一些特殊的功能(比如请求重试、缓存、自动重定向等),可以使用现成的第三方库,而不是自己重复造轮子。比如:
- axios-retry:轻松实现请求重试
- axios-cache-adapter:请求缓存
- axios-auth-refresh:自动刷新 token
这些库都能与 axios 配合得很好,帮助你解决二次封装中出现的某些功能问题,避免在项目中增加冗余的封装层。
4. 模块化的请求管理
而对于需要统一管理的 api 请求,推荐将每个请求模块化,分层管理,而不是在一个封装类中把所有请求都硬编码。你可以根据需求将每个 api 的请求抽象成一个独立的函数或模块,保持高内聚低耦合的设计。
// api/user.js
export function getUserInfo(userId) {
return axios.get(`/users/${userId}`);
}
这样做的好处是,当某个接口发生变化时,只需要修改相应的模块,而不需要担心影响到其他的请求。
总结
二次封装 axios 是一种源自“代码复用”的良好初衷,但它往往带来了灵活性不足、复杂度增加、性能损失等一系列问题。在面对实际开发中的 http请求时,我们可以通过直接使用 axios 的内置功能、合理利用拦截器、借助现成的第三方库以及模块化管理等方式来更高效、更优雅地解决问题。
所以,不要再二次封装 axios 了,它并不是“灵丹妙药”。让我们回归简单,享受 axios 原生的优雅与高效吧!
来源:juejin.cn/post/7441853217522204681
不懂这些GIS基础,开发Cesium寸步难行!
大家好,我是日拱一卒的攻城师不浪,专注可视化、数字孪生、前端提效、nodejs、AI学习、GIS等学习沉淀,这是2024年输出的第30/100篇文章。
前言
想学Cesium开发
,你如果对一些GIS基础,特别是坐标系
概念不了解的话,会让你非常难受,今天我们就来聊聊WebGiser
开发过程中常用到的一些坐标系概念。
GIS坐标系
要熟悉Cesium中常用到的一些坐标类型以及它们之间是如何进行转换的,到了真正用到的时候可以再返回来细看,加深理解。
经纬度坐标(球面坐标)
经纬度坐标通常被称为地理坐标
或地球坐标
,它是一种基于地球表面的坐标系统,用于确定地球上任何点的位置。这种坐标系统使用两个主要的数值来表示位置:经度和纬度。
- 经度(Longitude):表示从本初子午线(通常通过英国伦敦的格林尼治天文台)向东或向西的角度距离。经度的范围是从 -180° 到 +180°,其中 0° 表示本初子午线。
- 纬度(Latitude):表示从赤道向北或向南的角度距离。纬度的范围是从 -90°(南极点)到 +90°(北极点),其中 0° 表示赤道。
经纬度坐标也常常被称为:
- 球面坐标(Spherical Coordinates):因为地球近似为一个球体,经纬度坐标可以看作是在球面上确定点的位置。
- 大地坐标(Geodetic Coordinates):在大地测量学中,这种坐标系统用于描述地球表面的点。
- WGS84坐标:WGS84(World Geodetic System 1984)是一种广泛使用的全球地理坐标系统,它提供了一个标准化的参考框架,用于地理定位。
经纬度坐标广泛应用于地图制作、导航、地理信息系统(GIS)、航空和海洋导航等领域。在数字地图服务和应用程序中,经纬度坐标是最常见的位置表示方式之一。
地理坐标(弧度)
在地理信息系统(GIS)中,地理坐标通常指的是地球上某个点的位置,使用经纬度来表示。然而,由于地球是一个近似的椭球体,使用弧度而非角度来表示经纬度坐标可以避免在计算中引入的某些复杂性,尤其是在进行距离和面积的测量时。
弧度是一种角度的度量单位,它基于圆的周长和半径之间的关系。一个完整的圆周被定义为 2π弧度。弧度与角度的转换关系如下:
在GIS中,使用弧度的地理坐标可以简化一些数学运算,尤其是涉及到三角函数和地球曲率的计算。例如,计算两点之间的大圆距离(即地球表面的最短路径)时,使用弧度可以更直接地应用球面三角学公式。
地理坐标(弧度)的应用
- 距离计算:使用球面三角学公式,可以更准确地计算出两点之间的距离。
- 方向计算:确定从一个点到另一个点的方向,使用弧度可以简化计算过程。
- 地图投影:在某些地图投影中,使用弧度可以更自然地处理地球表面的曲率。
屏幕坐标系
屏幕坐标系(Screen Coordinate System)是一种二维坐标系统,它用于描述屏幕上的点或区域的位置。屏幕坐标系通常以屏幕的左上角为原点,水平向右为 x 轴正方向,垂直向下为 y 轴正方向。
屏幕坐标系在Cesium中叫做二维笛卡尔
平面坐标。
new Cesium.Cartesian2(x, y)
屏幕坐标系的特点:
- 原点位置:屏幕坐标系的原点(0,0)位于屏幕的
左上角
。 - 正方向:x 轴正方向向右,y 轴正方向向下。
- 单位:通常使用像素(px)作为单位。
- 范围:坐标值的范围取决于屏幕或窗口的大小。
空间直角坐标系
在地理信息系统(GIS)中,空间直角坐标系(Spatial Cartesian Coordinate System)是一种三维坐标系统,用于在三维空间中精确地表示点、线、面的位置。这种坐标系通常由三个正交的坐标轴组成:X、Y 和 Z 轴。
空间直角坐标系的特点:
- 正交性:X、Y 和 Z 轴相互垂直,形成一个直角坐标系。
- 三维性:可以表示三维空间中的任何位置,包括高度或深度信息。
- 标准化:通常以地球的质心或某个参考点为原点,建立一个标准化的坐标系统。
- 应用广泛:广泛应用于地理测量、城市规划、建筑设计、3D 建模等领域。
Cesium中的坐标系
Cesium中支持两种坐标系:3D笛卡尔坐标系
和经纬度坐标系
;
3D笛卡尔坐标系
先来了解下笛卡尔空间直角坐标系,它的X、Y、Z三个轴的正方向如下图所示:
坐标系的原点位于地球的中心。因此,这些坐标通常是负的。单位通常是米
。
Cesium.Cartesian3(x, y, z)
地理坐标系
是一种基于经度和纬度的坐标系,它使用度数来表示位置。
在Cesium中,地理坐标可以通过将经度、纬度和高度值传递给Cartographic对象来表示。
其中经度和纬度是以度数表示的,高度值可以是以米或其他单位表示的。
Cesium将地理坐标转换为笛卡尔坐标以在地球表面上进行可视化。
坐标系转换
Cesium提供了很多坐标系互相转换的大类。
经纬度转空间直角
const cartesian3 = Cesium.Cartesian3.fromDegrees(lng, lat, height);
经纬度转地理坐标(弧度)
const radians = Cesium.Math.toRadians(degrees)
地理坐标(弧度)转经纬度
const degrees = Cesium.Math.toDegrees(radians)
空间直角转经纬度
// 先将3D笛卡尔坐标转为地理坐标(弧度)
const cartographic = Cesium.Cartographic.fromCartesian(cartesian3);
// 再将地理坐标(弧度)转为经纬度
const lat = Cesium.Math.toDegrees(cartographic.latitude);
const lng = Cesium.Math.toDegrees(cartographic.longitude);
const height = cartographic.height;
屏幕坐标转经纬度
// 监听点击事件,拾取坐标
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((e) => {
const clickPosition = viewer.scene.camera.pickEllipsoid(e.position);
const randiansPos = Cesium.Cartographic.fromCartesian(clickPosition);
console.log(
"经度:" +
Cesium.Math.toDegrees(randiansPos.longitude) +
", 纬度:" +
Cesium.Math.toDegrees(randiansPos.latitude)
);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
屏幕坐标转空间直角坐标
var cartesian3 = viewer.scene.globe.pick(viewer.camera.getPickRay(windowPostion), viewer.scene);
世界坐标转屏幕坐标
windowPostion = Cesium.SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, cartesian3);
结语
作者的Cesium系列课程**《Cesium从入门到实战》**即将完结,课程介绍:ww7rybwvygd.feishu.cn/docx/PG1TdA…
如果想自学Cesium的也可以参考我的【开源项目】:github.com/tingyuxuan2…
有需要进技术产品开发交流群(可视化&GIS)可以加我:brown_7778(备注来意),也欢迎
数字孪生可视化领域
的交流合作。
最后,如果觉得文章对你有帮助,也希望可以一键三连👏👏👏,支持我持续开源和分享~
来源:juejin.cn/post/7404091675666055209
苹果 visionOS for web
苹果的 Vision Pro 已经发布了,虽然没有拿到手,但我还是对它的操作界面充满了好奇。
我看到很多小伙伴写了 Windows for Web,Mac OS for Web,所以我也想来实现一下 Vision Pro 的系统主页。
一开始,我以为这不会太难,当头一棒的就是苹果祖传优势: 动画。
这动画,这模糊,还是从中心点开始逐渐向外层扩散,应该根据人眼的视觉特征进行设计的。
问题是,该如何实现呢?
模糊我知道怎么实现,
filter: blur(15px);
从中心点开始逐渐向外层扩散的效果,我直接来个
transition-delay: 0.1s;
一通操作之下,也实现就似是而非的效果。而且边缘处app图标的缓缓落下的效果也不好。
然后就是光影效果的实现,因为它的很美,让人很难忽略。
在 Vision Pro 系统演示中可以看出,为了模拟菜单栏使用了磨砂玻璃材质,而为了营造真实感,会模拟光照射到玻璃上而形成的光线边框。
我不知道这是不是菲涅尔效应,但问题是,这又该如何在前端实现呢?
我想到了 CSS Houdini,可以利用 Houdini 开放的底层能力 paint
函数来实现一个菜单栏效果。
if ('paintWorklet' in CSS) {
CSS.paintWorklet.addModule('data:text/javascript,' + encodeURIComponent(`
class FresnelAppRectPainter {
static get inputProperties() { return ['--light-angle']; }
paint(ctx, size, properties) {
const borderRadius = 30;
const fresnelColor = 'rgba(255, 255, 255, .9)';
const lightAngle = parseFloat(properties.get('--light-angle')[0]) || 0;
// 绘制圆角矩形
ctx.beginPath();
ctx.moveTo(borderRadius, 0);
ctx.lineTo(size.width - borderRadius, 0);
ctx.arcTo(size.width, 0, size.width, borderRadius, borderRadius);
ctx.lineTo(size.width, size.height - borderRadius);
ctx.arcTo(size.width, size.height, size.width - borderRadius, size.height, borderRadius);
ctx.lineTo(borderRadius, size.height);
ctx.arcTo(0, size.height, 0, size.height - borderRadius, borderRadius);
ctx.lineTo(0, borderRadius);
ctx.arcTo(0, 0, borderRadius, 0, borderRadius);
ctx.closePath();
ctx.fillStyle = 'rgba(163, 163, 163)';
ctx.fill();
// 模拟光照效果
const gradient = create360Gradient(ctx, size, lightAngle)
ctx.fillStyle = gradient;
ctx.fill();
// 添加菲涅尔效果
const borderGradient = ctx.createLinearGradient(0, 0, size.width, size.height);
borderGradient.addColorStop(0, fresnelColor);
borderGradient.addColorStop(0.2, 'rgba(255,255,255, 0.7)');
borderGradient.addColorStop(1, fresnelColor);
ctx.strokeStyle = borderGradient;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
registerPaint('fresnelAppRect', FresnelAppRectPainter);
`));
}
结果效果还可以,我甚至可以接收一个光的入射角度,来实时绘制光影效果。
function create360Gradient(ctx, size, angle) {
// 将角度转换为弧度
const radians = angle * Math.PI / 180;
// 计算渐变的起点和终点
const x1 = size.width / 2 + size.width / 2 * Math.cos(radians);
const y1 = size.height / 2 + size.height / 2 * Math.sin(radians);
const x2 = size.width / 2 - size.width / 2 * Math.cos(radians);
const y2 = size.height / 2 - size.height / 2 * Math.sin(radians);
// 创建线性渐变
const gradient = ctx.createLinearGradient(x1, y1, x2, y2);
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.2)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
return gradient;
}
演示效果图
哦对了,还有一个弹层底边角的缩放效果,我目前还没想到什么好办法来实现,年底还得抓紧搬砖,只能先搁置了,如果小伙伴们有好办法,欢迎告知或者讨论。
最终效果图
这里是 Demo 地址。
本来是冲着纯粹娱乐(蹭流量)来写的,但写着写着就发现好像没那么简单,三个晚上过去,也只写了个首页,不得不感慨苹果真的太细了呀。
以上。
来源:juejin.cn/post/7329280514627600425
美女运营老师,天天找我改配置,我用node给她写了个脚本,终于安静了
美女运营老师,天天找我改配置,给她写了个脚本,终于安静了
事情的起因是,加入到新的小组中,在开发低代码后台管理页面的需求,需要配置一些下拉选项,后端因为一些特殊的原因,没法提供api接口,所以需要前端写成配置选项。这样问题就来了,新增了选项,但是没有给前端配置。美女运营老师都会来找开发,说:为什么新导入的数据没有显示啊,是不是有bug。。找了一圈发现是配置没加
我让运营老师,把新增数据表格给我配置下,丢过来新增数据上来就是1000+,手动加要哭死。于是我就想能否用脚本生成一个。
刚开始借用在线CSV转换JSON
把csv下载到本地,转换成json,返回数据倒是能返回,但是不符合运营老师的要求,key值需要是 key: ${data.value}-${data.key}
于是我就写了下面第一个简单版的node脚本
const fs = require('fs')
const csv = require('csv-parser');
const uidsfilePath = './uids.json';
const datas = [`复制生成的json数据`];
let newarr = [];
format(datas);
fs.writeFile(uidsfilePath, JSON.stringify(newarr), () => {
console.log('done')
})
const format = (results) => {
newarr = results.map(item => {
return {
label: `${item.value}-${item.key}`,
value: item.value
}
})
}
okok 到这里可以生成了。但是吧,想把这个事情还给运营老师,嘿
于是我又在这个基础上加上了读取CSV文件,格式化数据,输出JSON文件
使用 csv-parser读取 csv文件
csv-parser 是一个为Node.js设计的高效且流线型库,专注于快速解析CSV数据。它旨在提供最小的开销,保持简单轻量,特别适配于Node.js的流处理。此库遵循RFC 4180标准,并通过csv-spectrum的酸性测试套件,确保了对各种CSV变体的广泛兼容性和正确性。性能方面,csv-parser在处理大文件时表现出色,无论是带引号还是不带引号的CSV数据。
快速使用csv-parser
开始使用csv-parser,首先确保你的环境中已安装Node.js。接着,通过npm安装
csv-parser:
npm install csv-parser
示例代码
const fs = require('fs');
const parse = require('csv-parser');
fs.createReadStream('yourfile.csv')
.pipe(parse({ headers: true }))
.on('data', (row) => {
console.log(row);
})
.on('end', () => {
console.log('CSV file successfully processed');
});
第二版脚本
直接获取csv文件,生成输出JSON
const fs = require('fs')
const csv = require('csv-parser');
const csvfilePath = './新增UID.csv';
const uidsfilePath = './uids.json';
const results = [];
let newarr = [];
fs.createReadStream(csvfilePath)
.pipe(csv({ headers: true }))
.on('data', (data) => {
results.push(data);
})
.on('end',async () => {
await format(results);
fs.writeFile(uidsfilePath, JSON.stringify(newarr), () => {
console.log('done')
})
});
const format = (results) => {
newarr = results.map(item => {
if(item._0 === 'key' || item._1 === 'value') {
return {}
}
return {
label: `${item._1}-${item._0}`,
value: item._1
}
})
}
部分生成的数据
到这里又节省了一些时间,但是运营老师既不会安装node,也不会使用命令执行node CSVtoJSON.js,太复杂了不会弄。。。我说你提个需求吧,后面给您做成页面上传csv文件,返回JSON数据,点击一键复制好不好。
仅供娱乐,欢迎吐槽
未完待续,持续更新中...
感谢关注点赞评论~
来源:juejin.cn/post/7442489501590044672
【环信uniapp uikit】手把手教你uniapp uikit运行到鸿蒙
写在前面:
好消息好消息,环信uniapp出uikit啦~
更好的消息,环信uniapp sdk也支持鸿蒙系统啦!!!!那么我们一起来看看uniapp uikit如何运行到鸿蒙系统~~let's go
uniapp uikit以及支持鸿蒙系统的uniapp sdk版本都是`4.11.0`
准备工作
1. HBuilderX 4.36
2. DevEco-Studio 5.0.5.310
3. sass:sass-loader 10.1.1 及之前版本
4. node:12.13.0 - 17.0.0,推荐 LTS 版本 16.17.0
5. npm:版本需与 Node.js 版本匹配
6. 已经在环信即时通讯云控制台创建了有效的环信即时通讯 IM 开发者账号,并获取了App Key
7. 了解uniapp创建运行鸿蒙系统
8. 了解uniapp UIkit各功能以及api调用
开始集成:
第一步:创建一个uniapp+vue3项目进度10%
第二步:安装依赖 进度15%npm init -y
npm i easemob-websdk@4.11.0 pinyin-pro@3.26.0 mobx@6.13.4 --save
第三步:下载uniapp uikit源码 进度20%git clone https://github.com/easemob/easemob-uikit-uniapp.git
第四步:拷贝uikit组件 进度50%
mkdir -p ./ChatUIKit
# macOS
mv ${组件项目路径}/ChatUIKit/* ./ChatUIKit
# windows
move ${组件项目路径}/ChatUIKit/* .\ChatUIKit
第五步:替换pages/index/index.vue文件 进度70%
第六步:替换app.vue文件 进度80%
第七步:在pages.json配置路由 进度90%
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "uni-app"
}
},
{
"path": "ChatUIKit/modules/Chat/index",
"style": {
"navigationStyle": "custom",
// #ifdef MP-WEIXIN
"disableScroll": true,
// #endif
"app-plus": {
"bounce": "none",
"softinputNavBar": "none"
}
}
},
{
"path": "ChatUIKit/modules/VideoPreview/index",
"style": {
"navigationBarTitleText": "Video Preview",
"app-plus": {
"bounce": "none"
}
}
}
]
}
第八步:运行到chrome浏览器看下效果 进度90%
第九步:运行到鸿蒙并发送一条消息 进度100%
遇到的问题:
问题1:
详细报错信息如下
hvigor ERROR: Invalid product for target 'default'.
Detail: Check the target applyToProducts field for 'default': [ 'default', 'release' ].
at /Users/admin/Desktop/ouyeel_worksheet/unpackage/debug/app-harmony-2f573459/build-profile.json5
解决方案:
在harmony-configs/build-profile.json5文件,复制default配置,将default改为relese,参考
问题2:
登录报错
解决方案:
在/harmony-configs/entry/src/main/module.json5文件添加以下代码
"requestPermissions": [ {"name": "ohos.permission.GET_NETWORK_INFO"}, { "name": "ohos.permission.INTERNET"}, ],
问题3:
HBuilderX无限重连
解决方案:
看看sdk 是不是最新版,无限重连的问题已经在 4.12.0版本的sdk修复~
总结:
初步运行到鸿蒙的话问题会比较多,大家可以善用百度大法解决掉它!!!
老板不让用ECharts,还想让我画很多圆环!
需求简介
这几天下来个新需求:要在页面上动态渲染多个表格,每个表格内还要实现若干圆环!
刚拿到这个需求的时候,我第一反应是用echarts
实现,简单快捷
然而,老板无情的拒绝了我!他说:
咱这个项目就一个独立页面,你引入个ECharts项目又要大很多!而且一个页面这么多ECharts实例,性能怎么保障?不准用ECharts,用CSS实现!
没办法,我只好百度如何用CSS画圆环。幸运的是,我确实找到了类似的文章:
不幸的事,效果和我的差异很大,代码根本 无法复用!没办法,只能用别的办法实现了。经过一番研究,最终借助Canvas搞出来了,简单的分享一下我的实现思路吧。
圆环组件简介
为了方便复用,我把这个功能封装成了项目可直接复用的组件。并支持自定义圆环大小、圆环宽度和圆环颜色比例配置属性。
<Ring
:storkWidth="5"
:size="60"
:ratioList="[
{ ratio: 0.3, color: '#FF5733' },
{ ratio: 0.6, color: '#33FF57' },
{ ratio: 0.1, color: '#3357FF' }
]"
></Ring>
技术方案
实现目标
根据我们的UX需求,我们需要实现一个简单的组件,该组件可以展示一个圆环图表,并根据外部传入的比例数据(如 ratioList
)绘制不同颜色的环形区域。
- 使用 Vue 3 和 TypeScript。
- 动态绘制环形图,根据传入的数据绘制多个环。
- 支持自定义环形图的大小和环宽。
创建 Vue 组件框架
首先,我们创建一个名为 RingChart.vue
的组件。组件的初始结构非常简单,只包含一个 canvas
元素。
<template>
<!-- 创建一个 canvas 元素,用于绘制图表 -->
<canvas ref="canvasDom"></canvas>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
// 获取 canvas DOM 元素的引用
const canvasDom = ref<HTMLCanvasElement | null>(null);
// 初始化 canvas 和上下文变量
let ctx: CanvasRenderingContext2D | null = null;
let width: number, height: number;
// 初始化 canvas 尺寸和绘图环境
const initCanvas = () => {
const dom = canvasDom.value;
if (!dom) return;
ctx = dom.getContext('2d');
if (!ctx) return;
// 设置 canvas 的宽高
dom.width = dom.offsetWidth;
dom.height = dom.offsetHeight;
width = dom.offsetWidth;
height = dom.offsetHeight;
};
// 在组件挂载后执行初始化
onMounted(() => {
initCanvas();
});
</script>
<style scoped>
canvas {
width: 100%;
height: 100%;
}
</style>
上述代码中,我们初始化了 canvas
元素,并且设定了 width
和 height
属性。
绘制基本的圆环
接下来,我们添加绘制圆环的功能:通过 arc
方法来绘制圆环,设置 lineWidth
来调整环的宽度。
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
// 获取 canvas DOM 元素的引用
const canvasDom = ref<HTMLCanvasElement | null>(null);
// 初始化 canvas 和上下文变量
let ctx: CanvasRenderingContext2D | null = null;
let width: number, height: number;
// 初始化 canvas 尺寸和绘图环境
const initCanvas = () => {
const dom = canvasDom.value;
if (!dom) return;
ctx = dom.getContext('2d');
if (!ctx) return;
// 设置 canvas 的宽高
dom.width = dom.offsetWidth;
dom.height = dom.offsetHeight;
width = dom.offsetWidth;
height = dom.offsetHeight;
// 调用绘制圆环的方法
drawCircle({
ctx,
x: width / 2,
y: height / 2,
radius: 8,
lineWidth: 4,
color: '#C4C9CF4D',
startAngle: -Math.PI / 2,
endAngle: Math.PI * 1.5,
});
};
// 绘制一个圆环的方法
const drawCircle = ({
ctx,
x,
y,
radius,
lineWidth,
color,
startAngle,
endAngle,
}: {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
radius: number;
lineWidth: number;
color: string;
startAngle: number;
endAngle: number;
}) => {
ctx.beginPath();
ctx.arc(x, y, radius, startAngle, endAngle);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
ctx.closePath();
};
onMounted(() => {
initCanvas();
});
</script>
drawCircle
函数是绘制圆环的核心。我们通过arc
方法绘制圆形路径,使用lineWidth
来调整环的宽度,并用strokeStyle
给圆环上色。startAngle
和endAngle
参数决定了圆环的起始和结束角度,通过改变它们可以控制环的覆盖区域。
绘制多个环形区域
现在,我们来实现绘制多个环形区域的功能。我们将通过传入一个 ratioList
数组来动态生成多个环,每个环代表不同的比例区域。
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
// 定义 props 的类型
interface RatioItem {
ratio: number;
color: string;
}
const props = defineProps<{
size?: number; // 画布大小
storkWidth?: number; // 环的宽度
ratioList?: RatioItem[]; // 比例列表
}>();
// 默认值
const defaultSize = 200;
const defaultStorkWidth = 4;
const defaultRatioList: RatioItem[] = [
{ ratio: 1, color: '#C4C9CF4D' },
];
// canvas DOM 和上下文
const canvasDom = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
// 动态计算 canvas 的中心点和半径
const size = computed(() => props.size || defaultSize);
const center = computed(() => ({
x: size.value / 2,
y: size.value / 2,
}));
const radius = computed(() => size.value / 2 - (props.storkWidth || defaultStorkWidth));
// 初始化 canvas
const initCanvas = () => {
const dom = canvasDom.value;
if (!dom) return;
ctx = dom.getContext('2d');
if (!ctx) return;
dom.width = size.value;
dom.height = size.value;
drawBackgroundCircle();
drawDataRings();
};
// 绘制背景圆环
const drawBackgroundCircle = () => {
if (!ctx) return;
drawCircle({
ctx,
x: center.value.x,
y: center.value.y,
radius: radius.value,
lineWidth: props.storkWidth || defaultStorkWidth,
color: '#C4C9CF4D',
startAngle: -Math.PI / 2,
endAngle: Math.PI * 1.5,
});
};
// 绘制数据圆环
const drawDataRings = () => {
const { ratioList = defaultRatioList } = props;
if (!ctx) return;
let startAngle = -Math.PI / 2;
ratioList.forEach(({ ratio, color }) => {
const endAngle = startAngle + ratio * Math.PI * 2;
drawCircle({
ctx,
x: center.value.x,
y: center.value.y,
radius: radius.value,
lineWidth: props.storkWidth || defaultStorkWidth,
color,
startAngle,
endAngle,
});
startAngle = endAngle;
});
};
// 通用绘制函数
const drawCircle = ({
ctx,
x,
y,
radius,
lineWidth,
color,
startAngle,
endAngle,
}: {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
radius: number;
lineWidth: number;
color: string;
startAngle: number;
endAngle: number;
}) => {
ctx.beginPath();
ctx.arc(x, y, radius, startAngle, endAngle);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
ctx.closePath();
};
// 监听画布大小变化
onMounted(() => {
initCanvas();
});
</script>
上述代码中,我们通过 ratioList
数组传递每个环的比例和颜色,使用 startAngle
和 endAngle
来控制每个环的绘制区域。其中,drawDataRings
函数遍历 ratioList
,根据每个数据项的比例绘制环形区域。
现在,我们的组件就实现完毕了,可以在其他地方引入使用了
<RingChart
:storkWidth="8"
:size="60"
:ratioList="[
{ ratio: 0.3, color: '#F8766F' },
{ ratio: 0.6, color: '#69CD90' },
{ ratio: 0.1, color: '#FFB800' }
]"
></RRingChart>
组件代码
<template>
<canvas ref="canvasDom"></canvas>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
// 定义 props 的类型
interface RatioItem {
ratio: number;
color: string;
}
const props = defineProps<{
size?: number; // 画布大小
storkWidth?: number; // 环的宽度
ratioList?: RatioItem[]; // 比例列表
}>();
// 默认值
const defaultSize = 200; // 默认画布宽高
const defaultStorkWidth = 4;
const defaultRatioList: RatioItem[] = [{ ratio: 1, color: '#C4C9CF4D' }];
// canvas DOM 和上下文
const canvasDom = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
// 动态计算 canvas 的中心点和半径
const size = computed(() => props.size || defaultSize);
const center = computed(() => ({
x: size.value / 2,
y: size.value / 2
}));
const radius = computed(() => size.value / 2 - (props.storkWidth || defaultStorkWidth));
// 初始化 canvas
const initCanvas = () => {
const dom = canvasDom.value;
if (!dom) return;
ctx = dom.getContext('2d');
if (!ctx) return;
dom.width = size.value;
dom.height = size.value;
drawBackgroundCircle();
drawDataRings();
};
// 绘制背景圆环
const drawBackgroundCircle = () => {
if (!ctx) return;
drawCircle({
ctx,
x: center.value.x,
y: center.value.y,
radius: radius.value,
lineWidth: props.storkWidth || defaultStorkWidth,
color: '#C4C9CF4D',
startAngle: -Math.PI / 2,
endAngle: Math.PI * 1.5
});
};
// 绘制数据圆环
const drawDataRings = () => {
const { ratioList = defaultRatioList } = props;
if (!ctx) return;
let startAngle = -Math.PI / 2;
ratioList.forEach(({ ratio, color }) => {
const endAngle = startAngle + ratio * Math.PI * 2;
drawCircle({
ctx,
x: center.value.x,
y: center.value.y,
radius: radius.value,
lineWidth: props.storkWidth || defaultStorkWidth,
color,
startAngle,
endAngle
});
startAngle = endAngle;
});
};
// 通用绘制函数
const drawCircle = ({
ctx,
x,
y,
radius,
lineWidth,
color,
startAngle,
endAngle
}: {
ctx: CanvasRenderingContext2D;
x: number;
y: number;
radius: number;
lineWidth: number;
color: string;
startAngle: number;
endAngle: number;
}) => {
ctx.beginPath();
ctx.arc(x, y, radius, startAngle, endAngle);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
ctx.closePath();
};
// 监听画布大小变化
watchEffect(() => {
initCanvas();
});
onMounted(() => {
initCanvas();
});
</script>
<style scoped>
canvas {
display: block;
margin: auto;
border-radius: 50%;
}
</style>
使用
<Ring
:storkWidth="5"
:size="60"
:ratioList="[
{ ratio: 0.3, color: '#FF5733' },
{ ratio: 0.6, color: '#33FF57' },
{ ratio: 0.1, color: '#3357FF' }
]"
></Ring>
总结
累了,今天不想写总结!
来源:juejin.cn/post/7444014749321510963
三维gis的就业前景如何
一、前言
三维GIS是一个伪概念,GIS是地理信息系统,三维GIS就是三维地理信息系统,在课本上,专业概念上,也没有这一说法吧,所以三维GIS,就是技术人员造概念拼凑造出来的,本质上就是GIS三维可视化。
二、详细介绍
GIS从广义层面分为两大方面,第一是数据及数据管理,第二是业务功能,其中业务,就是分析、显示、交互。按照云计算服务划分,分别对应着DAAS层和SAAS层。按照所谓三维GIS的说法,那就是三维数据管理、三维分析、三维显示、三维交互。目前三维GIS基本上就是围绕着三维数据、三维分析、三维数据显示以及三维交互这四方面展开,其中三维数据是主要表现力。
1.三维数据
三维GIS场景与游戏场景的最主要也是最本质区别在于,三维GIS的数据都是真实的,来源于真实世界映射数据,包括卫星影像、三维地形DEM/DSM/DTM、倾斜摄影、点云等各式各样数据。游戏场景绝大部分人工建模出来的理想中的场景。一个是面向现实,一个面向未来,如果把两者结合,是不是有“元宇宙”的味道了呢?
2.三维分析
三维分析,即基于三维数据进行的分析计算,三维即三维空间,三维空间的GIS分析,三维空间距离量算、三维体积量算、三维高度量算……本质上应该比二维分析更丰富才对。可惜的是,三维空间分析,绝大部分在课本上找不到,更别说概念化描述。
3.三维可视化
三维可视化绝大部分是三维数据的可视化,我们常见的有地形可视化、倾斜摄影可视化
4.三维交互
除了普通的漫游、缩放、旋转、更有俯仰、翻转等操作。从上面可以看出三维GIS重点还是在于可视化,做三维GIS可视化,两大核心是GIS+图形学。想要在三维GIS深耕,就需要在计算机图形学有很好的基础,较强的GIS开发概念,以及自己的想象力。而图形学是公认的成长缓慢的大后期,比C++成长更慢,更别说与Java、JavaScript等相提并论。因此以后把三维GIS作为自己的就业方向,前景很光明,即使以后不在GIS圈,去互联网游戏行业都能胜任,而且比纯游戏开发更有优势。
三、结语
目前GIS公司对三维GIS的开发需求很强烈,但是受限于大学所学,能在大学既能懂GIS又懂图形学的,凤毛麟角,所以三维GIS开发很抢手,但是学校都找不到人,绝大部分都是招来基础好的学生,从零开始培养。
学好三维GIS,不仅仅会一个Cesium、MapBox就完了,这最初级阶段,只是二次开发,熟悉了接口,你就需要从原理角度去熟悉底层原理,渲染管线、地形瓦片及3dtiles调度与渲染等等,这些都需要你去认真深入学习的,还有一点,也是很三维GIS的最重要的一件武器:shader。这是必须熟练掌握的,包括glsl,hlsl等。
最后,就是要学游戏引擎,目前,做三维GIS与游戏引擎结合的越来越紧密,随着信创产业的快速发展,UE以后会越来越得到应用,做三维GIS,离不开熟练使用UE或者Unity。以上是我个人的一些看法,希望看到有更多的人投入到三维GIS中去,创造越来越多很酷的三维GIS产品,服务社会,造福人类!
来源:juejin.cn/post/7337188759060267023
23年计算机科学与技术毕业生的2024 年终总结
### 2024年年终工作总结
引言
随着2024年的落幕,作为一名计算机科学与技术专业的毕业生,我有幸参与到一款聊天应用的开发工作中。这一年,我不仅在技术层面获得了长足的进步,也在团队协作和项目管理方面积累了宝贵的经验。以下是我这一年工作的详细总结。
技术成长
1. 安卓开发技能提升
在这一年中,我深入学习了Android开发框架,掌握了Kotlin和Java两种编程语言,熟悉了Android Studio开发环境。
学习并实践了Material Design设计原则,优化了用户界面和用户体验。
掌握了网络编程,包括HTTP协议、RESTful API以及使用Retrofit和Volley等网络库进行数据通信。
2. 项目管理与协作
参与了敏捷开发流程,学习了Scrum框架,每周参与Scrum会议,及时同步项目进度。
学会了使用Git进行版本控制,以及在GitHub上进行代码管理和团队协作。
3. 性能优化与问题解决
学习了Android性能优化的技巧,包括内存管理、布局优化和多线程编程。
通过日志分析和性能监控工具,定位并解决了多个应用崩溃和性能瓶颈问题。
项目成果
1. 聊天应用核心功能开发
负责聊天应用的核心聊天功能模块开发,包括消息发送、接收和展示。
实现了消息加密传输,保障用户通信安全。
2. 用户界面与交互设计
参与设计并实现了应用的用户界面,使其更加直观和易用。
根据用户反馈,迭代优化了多个界面元素,提升了用户满意度。
3. 跨平台兼容性测试
参与了应用的跨平台测试工作,确保应用在不同设备和操作系统版本上的兼容性和稳定性。
团队协作与领导力
1. 团队沟通与协作
作为团队的一员,我积极参与团队讨论,提出建设性意见,并协助解决同事遇到的问题。
学会了如何更有效地与团队成员沟通,提高了团队的整体效率。
2. 领导力的培养
在项目中担任小组长,负责协调小组内的工作分配和进度跟踪,锻炼了我的领导能力。
个人成长与反思
1. 自我学习与提升
通过在线课程和专业书籍,不断学习新的技术和行业动态,保持自己的竞争力。
反思过去一年的工作,认识到自己在时间管理和优先级排序上的不足,并制定了改进计划。
2. 职业规划
明确了自己的职业发展方向,计划在未来几年内深入学习人工智能和机器学习,为公司的技术创新贡献力量。
2024年对我来说是充满挑战和成长的一年。我不仅在技术上有所提升,也在团队协作和项目管理方面获得了宝贵的经验。展望未来,我将继续努力,为公司的发展贡献自己的力量,并实现个人职业生涯的进一步发展。 环信的easyimkit很好用。
半死不活的2024
生活篇:
1、今年是否完成了脱单/结婚:又是等花花的一年!
2、描述今年的身体状况:马马虎虎,还活着
3、描述今年的精神状态:癫疯
4、今年最令你高兴/开心的那个瞬间:面的学校都收到offer了
5、 今年最大的收获是什么:心态更平稳
6、 实现了年初的什么目标:好好学习(?)
7、新增了哪一项热爱的东西:捡垃圾(包括但不限于手柄、充电宝、路由器)
8、失去了哪一项曾经热衷的东西:心气
9、今年你一定增长了很多见识,请问最让你印象深刻的是什么?为啥?:遇上了很多人,有好有坏,人教人学不会,事儿教人一次就会
10、一句话描述现在的自己:
正在向着理想中的地方的学校半死不活的间歇性挣扎着(冲冲冲)
工作篇:
11、今年跳槽了吗?冇
12、今年失业了吗?冇
13、今年涨薪了吗?冇
14、加班占工作时间的百分比:(或具体描述加班情况)看ddl什么时候通知
15、发量占头的百分比:怒发冲冠
收起阅读 »2024年年终总结 — 移动端开发者的路该怎么走
2024年即将结束,作为一名移动端开发者,回顾过去的一年,心中充满了感慨与收获。这一年,移动端开发领域依旧充满挑战与机遇,新技术的涌现、行业需求的变化以及团队协作的深化,都促使我们不断调整自己的发展路径和技术积累。
2024年,移动端开发领域发生了诸多变化,其中最显著的是跨平台开发的兴起。Flutter 和 UniApp 等等作为最流行的跨平台框架,逐渐在大型项目中得到应用。然而原生开发者们的路越走越窄。
2024年,随着AI技术的发展,越来越多的智能化应用涌现。作为移动端开发者,学习如何将AI技术应用于移动端开发中,未来移动端开发将不再仅仅局限于传统的UI和功能实现,AI、AR/VR以及5G等技术将逐步融入到移动应用中,带来更加丰富和个性化的用户体验。
2025年,是机遇还是更加刺骨的寒冬,拭目以待。
我的2024
作为一名前端开发者,2024年的年终总结应该既反映技术层面的成长,又体现工作中的挑战与收获。以下是一个适合前端开发者的年终总结框架,涵盖技术发展、项目经验、软技能提升等多个维度:
2024年是我技术进步的重要一年,掌握了很多新的前端技术和工具,深入了解了前端生态的最新趋势。具体而言,我在以下几个方面取得了显著进步:
JavaScript:加强了对ES6+新特性(如解构赋值、箭头函数、Promise、async/await、模块化等)的理解,提升了编写现代化代码的能力。尤其是在异步编程和状态管理方面,有了更深入的掌握。
框架与库:深入学习并应用了React和Vue,尤其是在React中,使用了React Hooks和Context进行组件管理,提升了代码的可维护性与复用性。Vue 3的Composition API也让我在处理复杂状态时更加高效。
前端工具链:深入掌握了Webpack、Vite等构建工具,优化了构建流程和性能。
熟悉了TypeScript,不仅能更好地进行类型检查,还提高了开发效率。
学习了ESLint、Prettier等代码规范工具,规范了团队的代码质量。
CSS与设计系统:
在CSS方面,掌握了Flexbox、Grid布局,更加灵活地解决了复杂的布局问题。
参与了公司设计系统的建设,理解并实现了组件化开发,提高了UI组件的复用率与一致性。
学习了CSS-in-JS的方案(如styled-components),使得在React项目中可以更好地管理样式。
性能优化:
深入理解了前端性能优化的关键技术,如代码分割、懒加载、图片优化、减少重排重绘等,提高了应用的加载速度与响应性能。
前端工程化:引入了CI/CD流程,搭建了自动化构建与部署的流程,减少了人工干预,提升了开发效率。在项目中积极推动模块化和组件化的开发,推动了代码重构,使得代码更加易于维护和扩展。
回顾2024年,作为一名前端开发者,能运用vue、react等语言进行前端开发。我在技术和工作能力上都有了很大的进步。在未来,我会继续保持学习的热情,不断提升自己的技术水平和软技能,为团队和公司创造更多的价值。希望在接下来的工作中能够实现更多的突破,迎接新的挑战,取得更大的成就!
对于学习了react并且用react开发工程,是我最大的收获
收起阅读 »我的2024年终终结
回望过去的这一年,我在技术领域经历了显著的成长与转型。作为一名拥有多年安卓开发经验的工程师,我见证了安卓平台从蓬勃兴起到逐步融入多元化技术生态的历程。尽管安卓原生开发依然占据重要地位,但不可否认的是,技术的浪潮已经带来了新的变革。
随着小程序、UniApp等新兴技术的崛起,我深刻感受到了市场对于快速迭代、跨平台兼容性的强烈需求。这些新技术不仅极大地提升了开发效率,还为用户带来了更加流畅、一致的使用体验。因此,我积极拥抱变化,将自己的技能树向这些新领域拓展。
在过去的一年里,我不仅继续深化了安卓原生开发的能力,还深入学习了小程序和UniApp的开发技术。通过实际项目的历练,我掌握了如何快速构建小程序应用,以及如何利用UniApp实现一次开发、多端运行的高效开发模式。这些经历不仅丰富了我的技术栈,也让我更加自信地面对多变的市场需求。
同时,我也积极参与了后台开发的工作。通过深入了解后端架构和数据处理流程,我对于整个技术链条有了更加全面的认识。这种跨领域的实践不仅提升了我的综合技术能力,也让我在团队协作中发挥了更大的价值。
展望未来,我将继续紧跟技术发展的步伐,不断学习和探索新的技术领域。我相信,只有不断适应变化、勇于创新,才能在激烈的市场竞争中立于不败之地。期待在新的一年里,我能够为公司贡献更多的价值,同时也实现自我价值的不断提升。
收起阅读 »我乱七八糟的2024
**App开发团队2024年年终总结报告**
**一、引言**
随着2024年的圆满结束,我们的App开发团队在这一年中取得了显著的成就。本报告旨在详细回顾我们的工作成果、总结经验教训,并为2025年的发展规划提供指导。
**二、项目回顾**
1. **项目概览**
- **项目A:健康追踪App**
- 目标:为用户提供一个全面的健康追踪和管理平台。
- 成果:成功上线,用户反馈积极,下载量突破100万。
- **项目B:在线教育平台**
- 目标:打造一个互动性强、资源丰富的在线学习环境。
- 成果:完成Beta测试,用户增长率达到20%。
2. **关键里程碑**
- **项目A:**
- 1月:项目启动。
- 6月:完成初步开发,开始内部测试。
- 9月:正式上线。
- **项目B:**
- 4月:项目启动。
- 8月:完成初步开发,开始Beta测试。
- 12月:收集反馈,准备全面上线。
3. **技术亮点**
- **项目A:**
- 引入了最新的机器学习算法,为用户提供个性化健康建议。
- **项目B:**
- 采用了最新的实时通讯技术,增强了师生间的互动体验。
**三、成果与数据分析**
1. **用户增长**
- **项目A:**
- 新增用户:150万
- 活跃用户:60万
- **项目B:**
- 新增用户:50万
- 活跃用户:10万
2. **用户反馈与市场表现**
- **项目A:**
- 用户满意度:90%
- 应用商店排名:健康类App第3位
- **项目B:**
- 用户满意度:85%
- 应用商店排名:教育类App第5位
3. **财务概况**
- **项目A:**
- 收入:500万美元
- 成本:300万美元
- 利润:200万美元
- **项目B:**
- 收入:100万美元
- 成本:80万美元
- 利润:20万美元
**四、团队建设与协作**
1. **团队规模与构成**
- 团队规模从20人增长至30人,包括5名新加入的高级工程师和2名产品经理。
2. **团队文化与氛围**
- 举办了多次团队建设活动,增强了团队凝聚力和创新能力。
3. **跨部门协作**
- 与市场部门合作,成功推广了项目A,与客服部门合作,提高了用户满意度。
**五、挑战与应对**
1. **遇到的主要挑战**
- **项目A:**
- 用户隐私保护问题。
- **项目B:**
- 内容更新速度跟不上用户需求。
2. **解决方案与调整**
- **项目A:**
- 加强数据加密和隐私政策的透明度。
- **项目B:**
- 增加内容团队,提高内容更新频率。
**六、经验总结**
1. **成功经验**
- **项目A:**
- 个性化推荐系统大幅提升了用户粘性。
- **项目B:**
- 实时反馈机制有效提高了学习效率。
2. **教训与反思**
- **项目A:**
- 需要更早地关注用户隐私问题。
- **项目B:**
- 内容质量比数量更重要,需要更注重内容的深度和质量。
**七、未来规划**
1. **技术趋势与创新**
- 计划引入更多的AI技术,提升用户体验。
2. **项目规划**
- **项目C:**
- 计划开发一款面向儿童的教育游戏App。
3. **团队发展**
- 计划增加10名开发人员,以支持新项目的启动。
**八、结语**
感谢团队成员一年来的辛勤工作和贡献。我们期待在新的一年里,继续携手前进,创造更多的价值和成就。
智源最新评测:豆包视觉理解模型排名全球第二
12月19日,智源研究院发布最新一期大模型综合及专项评测结果。在覆盖国内外100余个开源和商业闭源大模型的评测中,豆包通用模型pro获得大语言模型主观评测最高分;在多模态模型评测中,豆包视觉理解模型排名视觉语言模型第二,成绩仅次于GPT-4o;豆包文生图模型、豆包视频生成模型(即梦P2.0 pro)也分别在相应测试中获得全球第二。
据智源研究院介绍,大模型评测平台FlagEval联合了全国10余家高校和机构合作共建。此次公布的榜单中,大语言模型主观评测重点考察的是模型的中文能力,多模态模型评测榜单中,视觉语言模型主要考察的是模型在图文理解、长尾视觉知识、文字识别以及复杂图文数据分析能力。FlagEval大模型角斗场则是向用户开放的模型对战评测服务,反映了用户对模型的偏好。
在大语言模型主观评测中,豆包通用模型pro的知识运用和推理能力均获得最高分,简单理解、数学能力、安全等项目也排名前三,最终综合成绩排名第一。在FlagEval大模型角斗场榜单中,基于模型对战的用户投票结果,豆包通用模型pro得分排名第二,仅次于OpenAI的o1-mini。
在多模态模型评测榜单中,GPT-4o在视觉语言模型中排名第一,豆包视觉理解模型获第二。在中文的通用知识、文字识别中,豆包表现突出,相比国外模型有较大优势。在文生图测试中,混元和豆包排名前两位;在文生视频测试中,国产模型更是优势显著,可灵1.5高品质版、即梦P2.0 pro、爱诗科技PixVerse v3和海螺AI排名前列。
据悉,豆包视觉理解模型在不久前的火山引擎Force大会上首次发布,现已对企业客户开放使用。火山引擎方面表示,豆包大模型通过算法、工程、软硬件结合的技术创新,大幅优化使用成本,让每一家企业都能用得起,推动AI技术普惠和应用发展。(作者:李双)
收起阅读 »我发现很多程序员都不会打日志。。
大家好,我是程序员鱼皮。我发现很多程序员都不打日志,有的是 不想 打、有的是 意识不到 要打、还有的是 真不会 打日志啊!
前段时间的模拟面试中,我问了几位应届的 Java 开发同学 “你在项目中是怎么打日志的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println()
打印一下吧。。。
要知道,日志是我们系统出现错误时,最快速有效的定位工具,没有日志给出的错误信息,遇到报错你就会一脸懵逼;而且日志还可以用来记录业务信息,比如记录用户执行的每个操作,不仅可以用于分析改进系统,同时在遇到非法操作时,也能很快找到凶手。
因此,对于程序员来说,日志记录是重要的基本功。但很多同学并没有系统学习过日志操作、缺乏经验,所以我写下这篇文章,分享自己在开发项目中记录日志的方法和最佳实践,希望对大家有帮助~
一、日志记录的方法
日志框架选型
有很多 Java 的日志框架和工具库,可以帮我们用一行代码快速完成日志记录。
在学习日志记录之前,很多同学应该是通过 System.out.println
输出信息来调试程序的,简单方便。
但是,System.out.println
存在很严重的问题!
首先,System.out.println
是一个同步方法,每次调用都会导致 I/O 操作,比较耗时,频繁使用甚至会严重影响应用程序的性能,所以不建议在生产环境使用。此外,它只能输出简单的信息到标准控制台,无法灵活设置日志级别、格式、输出位置等。
所以我们一般会选择专业的 Java 日志框架或工具库,比如经典的 Apache Log4j 和它的升级版 Log4j 2,还有 Spring Boot 默认集成的 Logback 库。不仅可以帮我们用一行代码更快地完成日志记录,还能灵活调整格式、设置日志级别、将日志写入到文件中、压缩日志等。
可能还有同学听说过 SLF4J(Simple Logging Facade for Java),看英文名就知道了,这玩意并不是一个具体的日志实现,而是为各种日志框架提供简单统一接口的日志门面(抽象层)。
啥是门面?
举个例子,现在我们要记录日志了,先联系到前台接待人员 SLF4J,它说必须要让我们选择日志的级别(debug / info / warn / error),然后要提供日志的内容。确认之后,SLF4J 自己不干活,屁颠屁颠儿地去找具体的日志实现框架,比如 Logback,然后由 Logback 进行日志写入。
这样做有什么好处呢?无论我们选择哪套日志框架、或者后期要切换日志框架,调用的方法始终是相同的,不用再去更改日志调用代码,比如将 log.info 改为 log.printInfo。
既然 SLF4J 只是玩抽象,那么 Log4j、Log4j 2 和 Logback 应该选择哪一个呢?
值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一个作者(俄罗斯程序员 Ceki Gülcü)。
首先,Log4j 已经停止维护,直接排除。Log4j 2 和 Logback 基本都能满足功能需求,那么就看性能、稳定性和易用性。
- 从性能来说,Log4j 2 和 Logback 虽然都支持异步日志,但是 Log4j 基于 LMAX Disruptor 高性能异步处理库实现,性能更高。
- 从稳定性来说,虽然这些日志库都被曝出过漏洞,但 Log4j 2 的漏洞更为致命,姑且算是 Logback 得一分。
- 从易用性来说,二者差不多,但 Logback 是 SLF4J 的原生实现、Log4j2 需要额外使用 SLF4J 绑定器实现。
再加上 Spring Boot 默认集成了 Logback,如果没有特殊的性能需求,我会更推荐初学者选择 Logback,都不用引入额外的库了~
使用日志框架
日志框架的使用非常简单,一般需要先获取到 Logger 日志对象,然后调用 logger.xxx(比如 logger.info)就能输出日志了。
最传统的方法就是通过 LoggerFactory 手动获取 Logger,示例代码如下:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);
public void doSomething() {
logger.info("执行了一些操作");
}
}
上述代码中,我们通过调用日志工厂并传入当前类,创建了一个 logger。但由于每个类的类名都不同,我们又经常复制这行代码到不同的类中,就很容易忘记修改类名。
所以我们可以使用 this.getClass
动态获取当前类的实例,来创建 Logger 对象:
public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
public void doSomething() {
logger.info("执行了一些操作");
}
}
给每个类都复制一遍这行代码,就能愉快地打日志了。
但我觉得这样做还是有点麻烦,我连复制粘贴都懒得做,怎么办?
还有更简单的方式,使用 Lombok 工具库提供的 @Slf4j
注解,可以自动为当前类生成一个名为 log
的 SLF4J Logger 对象,简化了 Logger 的定义过程。示例代码如下:
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MyService {
public void doSomething() {
log.info("执行了一些操作");
}
}
这也是我比较推荐的方式,效率杠杠的。
此外,你可以通过修改日志配置文件(比如 logback.xml
或 logback-spring.xml
)来设置日志输出的格式、级别、输出路径等。日志配置文件比较复杂,不建议大家去记忆语法,随用随查即可。
二、日志记录的最佳实践
学习完日志记录的方法后,再分享一些我个人记录日志的经验。内容较多,大家可以先了解一下,实际开发中按需运用。
1、合理选择日志级别
日志级别的作用是标识日志的重要程度,常见的级别有:
- TRACE:最细粒度的信息,通常只在开发过程中使用,用于跟踪程序的执行路径。
- DEBUG:调试信息,记录程序运行时的内部状态和变量值。
- INFO:一般信息,记录系统的关键运行状态和业务流程。
- WARN:警告信息,表示可能存在潜在问题,但系统仍可继续运行。
- ERROR:错误信息,表示出现了影响系统功能的问题,需要及时处理。
- FATAL:致命错误,表示系统可能无法继续运行,需要立即关注。
其中,用的最多的当属 DEBUG、INFO、WARN 和 ERROR 了。
建议在开发环境使用低级别日志(比如 DEBUG),以获取详细的信息;生产环境使用高级别日志(比如 INFO 或 WARN),减少日志量,降低性能开销的同时,防止重要信息被无用日志淹没。
注意一点,日志级别未必是一成不变的,假如有一天你的程序出错了,但是看日志找不到任何有效信息,可能就需要降低下日志输出级别了。
2、正确记录日志信息
当要输出的日志内容中存在变量时,建议使用参数化日志,也就是在日志信息中使用占位符(比如 {}
),由日志框架在运行时替换为实际参数值。
比如输出一行用户登录日志:
// 不推荐
logger.debug("用户ID:" + userId + " 登录成功。");
// 推荐
logger.debug("用户ID:{} 登录成功。", userId);
这样做不仅让日志清晰易读;而且在日志级别低于当前记录级别时,不会执行字符串拼接,从而避免了字符串拼接带来的性能开销、以及潜在的 NullPointerException 问题。所以建议在所有日志记录中,使用参数化的方式替代字符串拼接。
此外,在输出异常信息时,建议同时记录上下文信息、以及完整的异常堆栈信息,便于排查问题:
try {
// 业务逻辑
} catch (Exception e) {
logger.error("处理用户ID:{} 时发生异常:", userId, e);
}
3、控制日志输出量
过多的日志不仅会占用更多的磁盘空间,还会增加系统的 I/O 负担,影响系统性能。
因此,除了根据环境设置合适的日志级别外,还要尽量避免在循环中输出日志。
可以添加条件来控制,比如在批量处理时,每处理 1000 条数据时才记录一次:
if (index % 1000 == 0) {
logger.info("已处理 {} 条记录", index);
}
或者在循环中利用 StringBuilder 进行字符串拼接,循环结束后统一输出:
StringBuilder logBuilder = new StringBuilder("处理结果:");
for (Item item : items) {
try {
processItem(item);
logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
} catch (Exception e) {
logBuilder.append(String.format("失败[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
}
}
logger.info(logBuilder.toString());
如果参数的计算开销较大,且当前日志级别不需要输出,应该在记录前进行级别检查,从而避免多余的参数计算:
if (logger.isDebugEnabled()) {
logger.debug("复杂对象信息:{}", expensiveToComputeObject());
}
此外,还可以通过更改日志配置文件整体过滤掉特定级别的日志,来防止日志刷屏:
<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允许 INFO 级别及以上的日志通过 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<!-- 配置其他属性 -->
</appender>
4、把控时机和内容
很多开发者(尤其是线上经验不丰富的开发者)并没有养成记录日志的习惯,觉得记录日志不重要,等到出了问题无法排查的时候才追悔莫及。
一般情况下,需要在系统的关键流程和重要业务节点记录日志,比如用户登录、订单处理、支付等都是关键业务,建议多记录日志。
对于重要的方法,建议在入口和出口记录重要的参数和返回值,便于快速还原现场、复现问题。
对于调用链较长的操作,确保在每个环节都有日志,以便追踪到问题所在的环节。
如果你不想区分上面这些情况,我的建议是尽量在前期多记录一些日志,后面再慢慢移除掉不需要的日志。比如可以利用 AOP 切面编程在每个业务方法执行前输出执行信息:
@Aspect
@Component
public class LoggingAspect {
@Before("execution(* com.example.service..*(..))")
public void logBeforeMethod(JoinPoint joinPoint) {
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
logger.info("方法 {} 开始执行", joinPoint.getSignature().getName());
}
}
利用 AOP,还可以自动打印每个 Controller 接口的请求参数和返回值,这样就不会错过任何一次调用信息了。
不过这样做也有一个很重要的点,注意不要在日志中记录了敏感信息,比如用户密码。万一你的日志不小心泄露出去,就相当于泄露了大量用户的信息。
5、日志管理
随着日志文件的持续增长,会导致磁盘空间耗尽,影响系统正常运行,所以我们需要一些策略来对日志进行管理。
首先是设置日志的滚动策略,可以根据文件大小或日期,自动对日志文件进行切分。比如按文件大小滚动:
<!-- 按大小滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
如果日志文件大小达到 10MB,Logback 会将当前日志文件重命名为 app.log.1
或其他命名模式(具体由文件名模式决定),然后创建新的 app.log
文件继续写入日志。
还有按照时间日期滚动:
<!-- 按时间滚动 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>
上述配置表示每天创建一个新的日志文件,%d{yyyy-MM-dd}
表示按照日期命名日志文件,例如 app-2024-11-21.log
。
还可以通过 maxHistory
属性,限制保留的历史日志文件数量或天数:
<maxHistory>30</maxHistory>
这样一来,我们就可以按照天数查看指定的日志,单个日志文件也不会很大,提高了日志检索效率。
对于用户较多的企业级项目,日志的增长是飞快的,因此建议开启日志压缩功能,节省磁盘空间。
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>
上述配置表示:每天生成一个新的日志文件,旧的日志文件会被压缩存储。
除了配置日志切分和压缩外,我们还需要定期审查日志,查看日志的有效性和空间占用情况,从日志中发现系统的问题、清理无用的日志信息等。
如果你想偷懒,也可以写个自动化清理脚本,定期清理过期的日志文件,释放磁盘空间。比如:
# 每月清理一次超过 90 天的日志文件
find /var/log/myapp/ -type f -mtime +90 -exec rm {} ;
6、统一日志格式
统一的日志格式有助于日志的解析、搜索和分析,特别是在分布式系统中。
我举个例子大家就能感受到这么做的重要性了。
统一的日志格式:
2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 用户ID:12345 登录成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 用户ID:12345 登录失败,原因:密码错误
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 执行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置项 `timeout` 使用默认值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 应用启动成功,耗时:2.34秒
这段日志整齐清晰,支持按照时间、线程、级别、类名和内容搜索。
不统一的日志格式:
2024/11/21 14:30 登录成功 用户ID: 12345
2024-11-21 14:30:16 错误 用户12345登录失败!密码不对
DEBUG 执行SQL SELECT * FROM users WHERE id=12345
Timeout = default
应用启动成功
emm,看到这种日志我直接原地爆炸!
建议每个项目都要明确约定和配置一套日志输出规范,确保日志中包含时间戳、日志级别、线程、类名、方法名、消息等关键信息。
<!-- 控制台日志输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 日志格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
也可以直接使用标准化格式,比如 JSON,确保所有日志遵循相同的结构,便于后续对日志进行分析处理:
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<!-- 配置 JSON 编码器 -->
</encoder>
此外,你还可以通过 MDC(Mapped Diagnostic Context)给日志添加额外的上下文信息,比如用户 ID、请求 ID 等,方便追踪。在 Java 代码中,可以为 MDC 变量设置值:
MDC.put("requestId", "666");
MDC.put("userId", "yupi");
logger.info("用户请求处理完成");
MDC.clear();
对应的日志配置如下:
<!-- 文件日志配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<!-- 包含 MDC 信息 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
</encoder>
</appender>
这样,每个请求、每个用户的操作一目了然。
7、使用异步日志
对于追求性能的操作,可以使用异步日志,将日志的写入操作放在单独的线程中,减少对主线程的阻塞,从而提升系统性能。
除了自己开线程去执行 log 操作之外,还可以直接修改配置来开启 Logback 的异步日志功能:
<!-- 异步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>500</queueSize> <!-- 队列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 丢弃阈值,0 表示不丢弃 -->
<neverBlock>true</neverBlock> <!-- 队列满时是否阻塞主线程,true 表示不阻塞 -->
<appender-ref ref="CONSOLE" /> <!-- 生效的日志目标 -->
<appender-ref ref="FILE" />
</appender>
上述配置的关键是配置缓冲队列,要设置合适的队列大小和丢弃策略,防止日志积压或丢失。
8、集成日志收集系统
在比较成熟的公司中,我们可能会使用更专业的日志管理和分析系统,比如 ELK(Elasticsearch、Logstash、Kibana)。不仅不用每次都登录到服务器上查看日志文件,还可以更灵活地搜索日志。
但是搭建和运维 ELK 的成本还是比较大的,对于小团队,我的建议是不要急着搞这一套。
OK,就分享到这里,洋洋洒洒 4000 多字,希望这篇文章能帮助大家意识到日志记录的重要性,并养成良好的日志记录习惯。学会的话给鱼皮点个赞吧~
日志不是写给机器看的,是写给未来的你和你的队友看的!
更多
来源:juejin.cn/post/7439785794917072896
一种简单粗暴的大屏自适应方案,原理及案例
现状
现在最流行的大屏自适应手法: scale缩放
为了解决2d/3d的点击交互问题,通常设计成了2个层容器。图表层和2d/3d层。图表层负责缩放,2d/3d层保持100%显示,避免缩放引起的交互事件event问题。
下图是一个1920*1080的大屏示意图
使用常规的缩放方法,让大屏在窗口内最大化显示。大屏在不同的窗口中出现了空白区域,并没有充满整个屏幕。
新的方法
在缩放的基础上,对指定的要素进行贴边处理。我们希望上下吸附到窗口最上面和最下面。左右图表吸附到窗口的最左边和最右边。
这里面需要简单的计算,其中a是图表层 scale属性
var halftop = (window.innerHeight- (1080*a.scaleY))/2/a.scaleY;
var halfleft = (window.innerWidth- (1920*a.scaleX))/2/a.scaleX;
对指定id的容器,在resize事件中设置上下左右浮动。如下图
实战项目效果
注,下面图片中的数据指标、城市名、姓名、图像均为虚拟数据。
在实际应用中,一般1920*1080设计稿已宽屏为主,如果是竖屏大屏(下图6),需要设计竖屏UI。
你也可以下载该项目demo, 对窗口进行缩放查看效果 pan.baidu.com/s/1hE_C9x9i…
来源:juejin.cn/post/7444378390843768843
还在等后端接口?自己写得了
前言
前端:芜湖~静态页面写完,起飞
前端:接口能不能搞快点
后端:没空
前端:emmmmmm
迭代结束.....
老板:前端你怎么回事?搞这么慢
前端:
A:跳起来打老板
B:跳起来打后端
C:不干了
D:自己全干
E:继续挨骂
CABABABABABBABABABABBABD
当然是选择
Mock.js
啦(骗你的,我自己也不用)
Mock.js 的使用教程
一、什么是 Mock.js?
Mock.js 是一个用于生成随机数据的 JavaScript 库,它可以帮助开发者快速模拟后台接口返回的数据,常用于前端开发中的接口调试和数据展示。通过使用 Mock.js,前端开发者无需依赖后端接口就可以模拟真实的接口数据,提升开发效率。
Mock.js 支持的数据类型非常丰富,包括字符串、数字、日期、图片等,并且可以对数据进行自定义设置,模拟出不同的场景。
二、安装 Mock.js
Mock.js 是一个轻量级的库,可以通过 npm
或 yarn
安装:
# 使用 npm 安装
npm install mockjs --save
# 使用 yarn 安装
yarn add mockjs
如果你没有使用包管理工具,也可以直接在 HTML 页面中通过 <script>
标签引入 Mock.js:
<script src="https://cdn.jsdelivr.net/npm/mockjs@1.1.0/dist/mock.min.js"></script>
三、Mock.js 的基本使用
Mock.js 提供了一个全局的 Mock
对象,使用 Mock
对象,你可以轻松地创建模拟数据。
1. 使用 Mock.mock()
方法
Mock.mock()
是 Mock.js 的核心方法,用于创建模拟数据。它接受一个模板作为参数,根据这个模板生成相应的模拟数据。
示例:生成简单的随机数据
const Mock = require('mockjs');
// 模拟一个简单的用户数据对象
const userData = Mock.mock({
'name': '@name', // 随机生成姓名
'age|18-60': 25, // 随机生成 18-60 之间的年龄
'email': '@email', // 随机生成邮箱地址
});
console.log(userData);
在这个例子中,@name
、@email
等是 Mock.js 内置的随机数据生成规则,'age|18-60': 25
是一种范围随机生成规则,它会生成 18 到 60 之间的随机数。
模拟输出:
{
"name": "张三",
"age": 34,
"email": "example@example.com"
}
2. 模拟数组数据
Mock.js 还可以生成数组数据,支持定义数组长度以及每个元素的生成规则。
const Mock = require('mockjs');
// 模拟一个包含多个用户的数组
const userList = Mock.mock({
'users|3-5': [{ // 随机生成 3 到 5 个用户对象
'name': '@name',
'age|20-30': 25,
'email': '@email'
}]
});
console.log(userList);
模拟输出:
{
"users": [
{ "name": "李四", "age": 22, "email": "user1@example.com" },
{ "name": "王五", "age": 28, "email": "user2@example.com" },
{ "name": "赵六", "age": 25, "email": "user3@example.com" }
]
}
3. 使用自定义规则生成数据
Mock.js 还支持自定义规则,你可以定义数据生成的规则,或者通过函数来生成特定的数据。
const Mock = require('mockjs');
// 使用自定义函数生成随机数据
const customData = Mock.mock({
'customField': () => {
return Math.random().toString(36).substr(2, 8); // 返回一个随机的 8 位字符串
}
});
console.log(customData);
模拟输出:
{
"customField": "rkf7hbw8"
}
四、常用的 Mock.js 模板规则
Mock.js 提供了丰富的数据生成规则,下面列出一些常用的规则。
1. 字符串相关规则
@name
:生成一个随机的中文名字。@cname
:生成一个随机的中文全名。@word(min, max)
:生成一个随机的单词,min
和max
控制长度。@sentence(min, max)
:生成一个随机的句子,min
和max
控制单词数量。@email
:生成一个随机的邮箱地址。@url
:生成一个随机的 URL 地址。
2. 数字相关规则
@integer(min, max)
:生成一个随机整数,min
和max
控制范围。@float(min, max, dmin, dmax)
:生成一个随机浮点数,min
和max
控制范围,dmin
和dmax
控制小数点位数。@boolean
:生成一个随机布尔值。@date(format)
:生成一个随机日期,format
为日期格式,默认是yyyy-MM-dd
。@time(format)
:生成一个随机时间。
3. 其他类型
@image(size, background, foreground)
:生成一张图片,size
控制图片大小,background
控制背景色,foreground
控制前景色。@guid
:生成一个 GUID。@id
:生成一个随机的身-份-证号。@province
、@city
、@county
:生成随机的省、市、区名称。
五、Mock.js 用于模拟接口数据
Mock.js 常用于前端开发中模拟接口数据,帮助前端开发人员在没有后端接口的情况下进行开发和调试。可以通过 Mock.mock()
来拦截 HTTP 请求,并返回模拟的数据。
示例:模拟一个接口请求
假设我们有一个接口需要返回用户数据,我们可以使用 Mock.js 来模拟这个接口。
const Mock = require('mockjs');
// 模拟接口请求
Mock.mock('/api/users', 'get', {
'users|5-10': [{ // 随机生成 5 到 10 个用户数据
'id|+1': 1, // id 从 1 开始递增
'name': '@name',
'email': '@email',
'age|18-60': 25,
}]
});
console.log('接口已模拟,发送请求查看结果');
在上面的代码中,Mock.mock()
拦截了对 /api/users
的 GET 请求,并返回一个包含随机用户数据的对象。当前端代码请求 /api/users
时,Mock.js 会自动返回模拟的数据。
六、Mock.js 高级用法
1. 延迟模拟
有时你可能希望模拟网络延迟,Mock.js 支持使用 timeout
配置来延迟接口响应。
Mock.mock('/api/data', 'get', {
'message': '成功获取数据'
}).timeout = 2000; // 设置延迟时间为 2000ms (2秒)
2. 使用正则表达式生成数据
Mock.js 还支持通过正则表达式来生成数据。例如,生成一个特定格式的电话号码。
const phoneData = Mock.mock({
'phone': /^1[3-9]\d{9}$/ // 正则表达式生成一个中国大陆手机号
});
console.log(phoneData);
3. 动态修改数据
Mock.js 还允许你在数据生成后对其进行动态修改,可以通过调用 Mock.Random
对象来获取随机数据,并进一步自定义。
const random = Mock.Random;
const customData = {
name: random.name(),
email: random.email(),
phone: random.phone(),
};
console.log(customData);
七、总结
Mock.js 是一个强大的工具,可以帮助你快速生成模拟数据,尤其适用于前后端分离的开发模式,前端开发人员可以独立于后端接口进行开发和调试。Mock.js 提供了灵活的数据生成规则,支持随机数、日期、图片等多种类型,并且能够模拟 HTTP 接口请求,极大地提高了开发效率。
掌握 Mock.js 的基本用法,可以帮助你在开发过程中更加高效,减少对后端开发的依赖,提升整个项目的开发速度。
各位彦祖亦菲再见ヾ( ̄▽ ̄)ByeBye
来源:juejin.cn/post/7442515129173262351
一封写给离职小伙伴的信
前言
亲爱的小伙伴,当你看到这封信的时候,相信你大概率也是离职浪潮中的一员了,不管是被裁还是主动离职,相信在接下来的日子里,求职路上必定不会一帆风顺,势必要经历一番波折与挑战。
也许你才刚开始,此刻意气风发信心满满;也许你正在经历,此时彻底怀疑自我,将要放弃底线;也许你已经历过了,此时已是遍体鳞伤体无完肤,彻底摆烂……
不管屏幕前的你是哪一种,但请记住,这是每个人入世时社会老师要给我们上的第一节课。
在这里,我先分享一段平凡普通又心酸的求职历程,也希望通过这篇文章能给你一些启发和帮助,也衷心地希望你能够重拾信心,一路披荆斩棘!
关于我
我是双非一本毕业,计算机专业,目前毕业已有九年了,一直在远离家乡的北漂之地工作。由于母校没啥名气且名字中带有的小众地域性,非本省的人很少知道它,所以在外省找工作经常会被问你是不是本科毕业(尴尬😅),学历这一块姑且算是杂牌吧,起码在筛选简历时不具备任何优势。
其次,简历也没有太多的亮眼经历,没有大厂背景,基本都是中小厂的工作经历,普通到不能再普通了。
其三,关于年龄这一块,本人目前很接近35岁的“退休”年龄,属于被重点劝退互联网的年纪,触碰到了互联网年龄红线。
最后,这还不算最糟糕,还有更糟糕的面试门槛。
什么是糟糕的面试门槛?接着往下看。
由于去年家里出事了,辞职回了老家,之后也没着急出来找工作,期间闲来无事,顺便考了个公(原本辞职也不是为了考公),结果可想而知,也没考上,所以也没当回事,该玩玩,该旅游就旅游,彻底放飞自我~(没有房贷车贷毫无顾忌)
等我再次出来找工作的时候,已经离上一份工作的间隔有一年多了(准确的说是一年零两个月),也就是说我已经 Gap 一年多了,我还丝毫没意识到这将成为我求职路上的一个障碍。
面试的准备
7月初,我正式开始准备面试。首先更新了简历,回顾之前的项目并总结,梳理项目架构、流程图、负责的模块以及技术难点。同时看了看八股文复习基础知识,刷刷leetcode,时间过得很快,大概三周后开始投简历。
面试的门槛
Gap一年多的经历,让我在求职中非常被动,一线大厂全都因为这段 Gap 经历被 HR 前期的电话沟通中直接否掉,连一面的面试机会都没有。
只有一家公司例外,那就是字节,也是我面试的第一家公司。首先不得不表扬一下这家公司的用人态度,只是我准备不足仓促应付,以为刷了算法题就没啥大问题,结果人家来了一个手撕题(非算法),当场把我给整懵了,结果可想而知……其实本质上还是综合能力不够,但起码人家给了你面试的机会。
不管最终结果如何,单凭这种不问出身来者不拒,招人包容的胸襟与态度就值得点赞👍
因此,后面我能接到面试的机会只有中小厂,一间大厂都没有,这也许就是 Gap 太久要付出的代价之一吧。
面试的过程
求职的过程比较曲折,毕竟离开岗位已经一年多了,很多零零碎碎的知识要整理起来也不是一蹴而就的,因此求职期间,一边面试,一边不断总结经验,把之前做过的东西以及面试的空白知识慢慢梳理出来,并整理成博客。通过系统化的梳理与表达,自己的思路也开始有了更清晰的脉络和方向。
经过三个多月的面试,期间共投了三十多家公司的简历,排除一线大厂以及被其他公司pass掉的简历,其实真正接到面试机会的一共只有二十家左右。
期间有一段是没有面试的空白期,那段时间真的怀疑自己,很彷徨,是不是真的该投外包和od,这里没有贬低的意思,只是目前自己还没有养老的打算,同时也有自己的职业规划和方向,外包暂时还不在考虑范围之内。
从刚开始投的高薪大中厂,到后来的中小厂,虽然姿态一直在放低,但终归守住了自己的底线——那就是行业方向和薪资待遇。
面试中的奇葩
面试过程中也会遇到各种各样的面试官,结合自己曾经也做过面试官的经验,一些常规的套路基本是熟悉的,幸运的是遇到的绝大多数面试官都非常的nice,当然也遇到个别的奇葩。
比如这次遇到了一个思路清奇的二面面试官,一面聊得还挺好的,本以为二面面缘应该也不会差,没想到上来他就开始板着脸,似乎人人都欠他八百万似的,之所以如此,直到后来我才知道,原来他在怀疑我简历造假。等我介绍完项目,没问技术实现细节,而是开始扣字眼,这个项目公司内部的名字叫什么?为什么简历中没提这个项目的内部名字?xxx公司有海外方向吗?我全程耐心地解释,一度怀疑他才是我前司的员工,而我不是。
最后问了我那个项目的域名地址,那个项目我也只跟进了一期,没有太深的记忆,当时没有找出来,后来就草草结束了。
这里也怪我,由于离开前司一年多,项目域名早变了,而自己在整理项目时没有及时跟进,才导致如此尴尬局面,后来我才知道那个项目的域名早已换成了官网域名,把它整合进海外官网了。
其实我也非常理解他的这种行为,换成是我,遇到这种情况,我也会对面试者产生怀疑。然而从逆向思维的角度分析,像我这种非大厂背景,学历看着又像渣本的简历,怎么造,简历都不会好到哪里去吧,何况还有背调,我又何必费那个心思。想想都觉得有点滑稽~
这里也给自己总结两点经验:
- 凡是可能涉及到的点,都要一一回顾,有备无患,但说实话经历过那么多年的面试,不管是我面别人还是别人面我,问项目域名的我还是头一次遇到。
- 怀疑一旦开始,罪名就已经成立。不管你后面如何辩白,结局其实早已注定,还不如趁早结束。即使你有幸通过面试,将来在一位不信任你的领导下干活,也是一件非常心累的事。
面试的结果
经过将近20家公司的轮番摩擦,终于在10月底的时候,陆陆续续有了口头offer,又经过银行流水、背调、体检,最终拿到了3家公司的正式offer,两家小公司和一家独角兽。
那家独角兽公司我很早就知道,其实也一直是我想进的一家公司,因此毫无悬念,我最终选择了那家中厂独角兽,总包降了一点点,但总算还是属于平跳,这个结果在当下的环境,对我来说已经很难得了。
面试经验分享
这里不提供什么面经,因为每面的问题几乎都不一样,几乎没有碰到任何相同的一个问题(自我介绍、项目介绍和HR面除外)。
面了这么多公司,印象中只有一道题是重复的,所以即使给出各家公司的面经,真正轮到你面时,出的题也会因人而异。面经并不是圣经,只是可以作为一个难度级别的参考罢了,所以面经意义其实并不大。
这里我想分享更多的是个人职业的规划与成长。
其实面试的过程本身就是一个提升自我认知的过程。
面对如此困境,我想分享一下我是如何破局的。
作为一个普通、大龄、又gap一年多的普通程序猿,我首先做的便是潜下心来扪心自问,在这么激烈的竞争环境中,与其他人相比,我的优势在哪?
- 首先是职业稳定性,我虽然没有大厂背景,但还算稳定,总共经历3家公司,只有中间一家是因疫情原因而不满一年,八年时间里没有频繁跳槽还算稳定。
- 其次是职业方向,个人之前从事的行业一直与电商领域相关,前几段工作经历一直是从事海外电商方向。因此,在海外电商这个领域中我有天然的行业经验优势,从最终拿到的3家offer公司的结果来看,也反向证明了这一点。
- 其三,投简历时我只会挑选特定的领域方向,我不会进行海投,更不会盲投,因为那没有什么意义,因为人家没看上你的,即便投了也不会有什么结果,最多只会礼貌地给你回访,并不会真正进入面试流程。
这是我在脉脉上得出的结论,因为对你发起的沟通是系统推荐,是系统发起的并不是他们本人,因此他们并不一定对你感兴趣(当然你若是那种天之骄子又有大厂背书,就请忽略我们这种平凡人的经历)。
因此,投简历时我一般只在boss上,并且是被动投简历,也就是别人先发起的简历请求。底层逻辑是因为只有人家对你感兴趣,你才会有更大的可能获得面试机会。
- 其四,珍惜每一次面试的机会,看清楚JD里的要求和公司从事的方向,对JD中描述的职位要求和职责,有针对性地准备面试,面试时遇到自己知识盲区要诚实表示不会,不要试图与面试官产生争论,因为即便你是对的,也会对你产生不利。坦然接受任何结果,放松心态。
- 其五,放低姿态,降低期望,期望越高,失望越高。由于我已经离职并 Gap 太久,没有任何骑驴找马的依仗,谈薪资时显得非常被动,这便是 Gap 太久的代价之二。
之前也面过几个到终面的公司,谈薪时过高而被Pass掉,我的诉求也很简单,就是期望能平薪而跳,也没指望能涨薪,也愿意接受适度降薪。
期间,有遇到过很爽快的,也有遇到拼命压你薪的,面了很多小公司都有这样一个相同的经历,到谈薪阶段,他们会故意冷淡你不跟你谈,而是经过多轮对比候选人,看看哪个性价比更高。其实也非常理解,毕竟当前的环境下,哪个老板不希望用低薪招到一个综合能力更强的人呢?
而有的会比较爽快,他们急着大量招人,流程会很快,也不会过分压薪,碰到这种公司那么恭喜你,你中奖了,这种基本在经济行情好(公司盈利大好)的时候才会出现。
放低姿态不意味着放弃原则反而要有更清晰的底线,它的底层逻辑是降低期望值,期望值是心理学上一个很巧妙东西,期望锚点值越低,才有更大的可能获得惊喜。
- 最后,主动沉淀和总结经验,对面试中经常问到的同一类问题进行总结和思考,进而纳入自己的脑海中,逐渐形成自己的知识体系。
比如,你为什么会从上一家公司离职?问这个问题的背后动机是什么?HR为什么会压你薪资?这些问题的背后原理一定要思考清楚,只有理解底层逻辑,才能应对自如。
当然,搞清楚这些问题的背后逻辑不是为了让你去说谎,面试时可以结合自身的实际,美化用词,但不要试图说谎。
总结
以上就是一个平凡普通又Gap一年多的打工人自我总结与分享,也希望它能给你带来一些启发和帮助,哪怕只有一点点,那也是我莫大的荣幸!
眼下的就业形势确实不容乐观,招聘越来越挑剔,要找到心仪的工作实属不易,但请不要因此而对生活失去信心,要相信好好总结与反思,总有一个位置属于你。
愿屏幕前的你能渡过难关、顺利上岸!加油!
一封来自远方陌生人的信
来源:juejin.cn/post/7444773769242116111
springboot多种生产打包方式简介
生产上发布 Spring Boot 项目时,流程颇为繁琐且低效。但凡代码有一丁点改动,就得把整个项目重新打包部署,耗时费力不说,生成的 JAR 包还特别臃肿,体积庞大。每次更新项目,光是上传这大文件就得花费不少时间,严重影响工作节奏。为解决这一痛点,我打算把依赖库以及配置文件(lib 文件夹下的那些 jar 包,还有config下的applacation.yml等文件)从项目主体里剥离出来,后续部署时,只需发布核心代码就行,这样既能加快部署速度,又能减轻文件传输负担,让项目更新变得轻松便捷
方法一 插件spring-boot-maven-plugin
1. 项目应用的配置文件排除 统一打包到config目录下
利用springboot中resource插件来排除配置,并统一打包到config目录下
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<!--指定目标路径为config-->
<targetPath>${project.build.directory}/config</targetPath>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 把我们写代码打包可执行jar,并排除依赖jar包
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 配置依赖的jar包 统一打包lib目录
<!--此插件用于将依赖包抽出-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
打包后目录结构,如下图所示
执行命令 java -jar -Dloader.path=./lib -jar xxx.jar
注意
springboot启动时候会优先读取config目录下配置文件 所以这里不用指定-Dspring.config.location=XX.yml文件
注意
例如日志文件配置以及mybits等配置文件 可以配成绝对路径 如下所示:
方法二 使用maven-jar-plugin插件实现
1 使用插件maven-resources-plugin处理配置文件打包到config目录
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!--配置文件打包成config目录下 -->
<outputDirectory>${project.build.directory}/twin-web/config</outputDirectory>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
2. 使用maven-jar-plugin 打包可执行jar 并排除依赖
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<outputDirectory>
<!--输入打包可执行的jar到twin-web\libs\下-->
${project.build.directory}/twin-web/
</outputDirectory>
<archive>
<addMavenDescriptor>false</addMavenDescriptor>
<manifest>
<addClasspath>true</addClasspath>
<!-- 增加执行启动jar的依赖jar包目录前缀-->
<classpathPrefix>./libs/</classpathPrefix>
<!-- 指定启动类-->
<mainClass>com.keqing.twinweb.TwinWebApplication</mainClass>
</manifest>
<manifestEntries>
<!-- 增加配置文件的classpath-->
<Class-Path>./config/</Class-Path>
</manifestEntries>
</archive>
<!-- 排除配置文件-->
<excludes>
<exclude>*.yml</exclude>
<exclude>mapper/**</exclude>
<exclude>*.xml</exclude>
</excludes>
</configuration>
</plugin>
3 使用maven-dependency-plugin 打包libs目录下
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/twin-web/libs</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
使用package打包后的目录
查看自己打包后jar目录,注意这种打包方式弊端,按照一定约定格式规范固定了,一旦依赖jar包(包括配置文件目录等)发生变化就必须重新打包
启动程序java -jar xxx.jar
方式三 使用maven-assembly-plugin打包
maven-assembly-plugin
是 Maven 中的一个插件,它允许用户将项目的输出以及依赖、模块、站点文档和其他文件打包成一个可发布的格式,例如 zip、tar.gz、jar 等。以下是使用 maven-assembly-plugin
的一些优势:
- 自定义打包格式:
maven-assembly-plugin
允许你通过定义描述符文件(descriptor)来完全自定义打包的内容和格式。你可以选择包含或排除特定的文件和目录。 - 一键打包:通过一个简单的 Maven 命令,你可以创建一个包含所有必需依赖的单一归档文件,这使得分发和部署变得非常简单。
- 多环境支持:可以为不同的环境(开发、测试、生产)创建不同的打包配置,使得环境迁移更加容易。
- 依赖管理:插件会自动处理项目依赖,将它们打包到最终的归档文件中,无需手动管理。
- 模块化项目支持:对于多模块项目,
maven-assembly-plugin
可以将所有模块的输出合并到一个归档文件中。 - 预配置的描述符:插件提供了一些预定义的描述符,如
bin
、jar-with-dependencies
等,可以直接使用,无需自定义。 - 灵活性:你可以通过修改描述符文件来调整打包行为,以适应不同的需求。
- 集成性:
maven-assembly-plugin
与 Maven 生态系统紧密集成,可以与其他 Maven 插件协同工作。 - 文档和社区支持:由于
maven-assembly-plugin
是 Maven 的一部分,因此有广泛的文档和社区支持。
1. 项目应用的配置文件排除
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 配置spring-boot-maven-plugin
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 引入springboot里约定maven-assembly-plugin
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<!-- 打包文件名字不包含 assembly.xml 中 id -->
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<!--项目所在目录配置文件的 assembly.xml文件 -->
<descriptor>assembly.xml</descriptor>
</descriptors>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
配置assembly.xml文件
<assembly>
<!-- 打包文件名的标识符,用来做后缀-->
<id>make-assembly</id>
<!-- 打包的类型,如果有N个,将会打N个类型的包 -->
<formats>
<format>tar.gz</format>
<format>zip</format>
</formats>
<!-- 压缩包下是否生成和项目名相同的根目录 -->
<includeBaseDirectory>true</includeBaseDirectory>
<!-- 用来设置一组文件在打包时的属性。-->
<fileSets>
<!-- 0755->即用户具有读/写/执行权限,组用户和其它用户具有读写权限;-->
<!-- 0644->即用户具有读写权限,组用户和其它用户具有只读权限;-->
<!-- 将src/bin目录下的jar启动脚本输出到打包后的目录中 -->
<fileSet>
<!--lineEnding选项可用于控制给定的行结束文件 -->
<lineEnding>unix</lineEnding>
<directory>${basedir}/bin</directory>
<outputDirectory>${file.separator}</outputDirectory>
<fileMode>0755</fileMode>
<includes>
<include>**.sh</include>
<include>**.bat</include>
</includes>
</fileSet>
<!-- 把项目的配置文件,打包进压缩文件的config目录 -->
<fileSet>
<directory>${basedir}/src/main/resources</directory>
<outputDirectory>config</outputDirectory>
<fileMode>0644</fileMode>
<includes>
<include>*.properties</include>
<include>*.yml</include>
<include>*.xml</include>
<include>mapper/*.xml</include>
</includes>
</fileSet>
<!-- 把项目自己编译出来的jar文件,打包进zip文件的根目录 -->
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>${file.separator}</outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
</fileSets>
<!-- 依赖包的拷贝-->
<dependencySets>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>provided</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>system</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
</assembly>
打包后目录
解压zip目录查看
使用命令启动项目java -jar -Dloader.path=./lib -jar xxx.jar
来源:juejin.cn/post/7442154802832916530
Spring Boot + liteflow竟然这么好用!
liteflow
可以帮助我们快速编排复杂的业务规则,并实现动态的规则更新。
在liteflow
中,主要有以下几种组件:
- 普通组件:集成
NodeComponent
,用于执行具体的业务逻辑;- 选择组件:通过业务逻辑选择不同的执行路径;
- 条件组件:基于条件返回结果,决定下一步的业务流程。
我们通过代码示例来了解每种组件的用法。
java
// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}
// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}
// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}
EL规则文件
在liteflow
中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。
如何使用EL规则文件
- 创建规则文件:将上述规则文件保存为
flow.xml
,放在项目的resources
目录下; - 配置
liteflow
:在Spring Boot项目中添加liteflow
的配置,指定规则文件的位置;
yaml
liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
- 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。
数据上下文
在liteflow
中,数据上下文非常重要,它用于参数传递
和业务逻辑
的执行。
我们可以通过以下代码示例了解数据上下文的用法
配置详解
在使用liteflow
时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。
以下是一个配置示例。
yaml
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
实践案例
接下来,我们来看一个完整的业务实践案例。
在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。
这时候,我们可以利用liteflow
进行规则编排,处理这些并行任务。
1. 引入依赖
首先,在pom.xml
文件中添加liteflow
的依赖:
xml
<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>
2. 增加配置
在application.yml
文件中添加liteflow
的配置:
yaml
spring:
application:
name: liteflow-demo
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
3. 编写规则文件
在resources
目录下创建flow.xml
文件,编写规则文件内容:
xml
<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>
4. 编写业务逻辑组件
按照规则文件中的定义,编写相应的业务逻辑组件:
java
@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}
@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}
5. 流程触发
当订单完成后,我们需要触发liteflow
的流程来执行积分发放和消息发送的逻辑。
我们可以在订单完成的服务方法中添加如下代码:
java
@Service
public class OrderService {
@Autowired
private FlowExecutor flowExecutor;
public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());
// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}
性能统计
总结
来源:juejin.cn/post/7394790673612718092
一位转行Java上岸4年到技术专家的经验分享
自我介绍:
大家好, 我是你们的朋友, 晓龙。今天我想分享一下我自己艰难的Java转行经历。
为什么没有学历 , 因为大学上了一学期, 当时因为一些原因, 就辍学了。年少无知 , 发现到社会上找工作, 没有学历就没有敲门砖,最低要求也是大专。在2018年报考了大专的网络教育。同时, 自己也开始了金融学本科的自考。2018年还是比较的迷茫, 也不知道能靠什么挣钱。2019年, 开始在黑马进行Java的培训, 因为大概有4年没有学习了,加之因为0基础, 当时去的时候连Java能干什么也不知道, 所以学起来还是比较的吃力,尤其我记得当时对方法的定义, 是真的理解不了。
开局buffer就叠满了,运气一直比较的好。
工作经历:
2019年9月左右, 在武汉经过几次面似乎,找到了第一家公司, 规模比较的小, 0-20人, 当然对于当时的我来说, 能找到一份工作(上岸),也是非常开心的, 但是很遗憾的是, 这家公司我去只坚持1个月, 公司就倒闭了。
但是也没有急着找工作了,花了20天, 花了20天把自考的科目复习,在11月份继续在武汉找工作,经过面试,入职了一家比较小的公司, 后端就2人,都是培训出来的。当时我的想法很明确, 花时间补知识, 在培训班的6个月, 时间还是比较的紧的,很多东西只是知道, 都没有亲自动手实践过。公司好在不是很忙, 有一定的时间学习。当时上了2个多月,就回家过年,后面因为yq , 就没有去武汉了,这家公司也就上了3个月的班, 一直在家呆到2020年年7月,期间把慕课网的架构课程好好学习了。7月开始在重庆找工作 ,经过了一周多的努力, 找到了一家更加不规范的公司, 后端2个, 前端一个, 产品一个,其他就没有了,还在薪资给的还可以, 这个期间继续学习, 真正的代码都写得比较的少。很遗憾, 公司只坚持了6个月, 2021年1月, 公司又倒闭了。
我清晰的记得当时准备面试的那一周,是压力最大的一周。在经历了前面几家公司后, 我也知道, 如果还去一家不稳定的公司, 结果还是这样, 几个月就倒闭了, 而且整个公司没有研发体系, 自己就永远不能真正的上岸(野路子到正规军)。虽然前面1年多代码没有写多少, 但是还是有一定的知识积累。
自己也没有放弃, 我当时在BOSS上看准了一家公司, 然后就开始好好的准备, 不仅仅是准备技术方面的知识, 还去了解公司的背景, 当时觉得这家公司规模比较的大。当时面试完后, 顺利的拿到了offer , 期间我和面试官(也是我的领导)说, 我非常期待能来公司上班,即使不要钱,但是没办法, 我自己得生活。直到现在, 我还是非常的感谢这位领导, 也会在微信中和他交流近况。
是的 ,从某种意义上讲, 现在才是真正的上岸!
我在这家公司感受到什么是真正的做开发。公司有正规的研发流程, 完善的研发体系, 每一个同事都身怀绝技。在这家公司, 我给自己的定位是2年成长为高级开发工程师,期间也暴露出我的一些问题,比如代码逻辑写得不清楚(之前代码写得太少了),设计做得不好等。
我是渴望成长的, 所以我针对自己具体的问题, 在2年的时间里, 充分利用自己的时间做了这些工作:
第一阶段: 在试用期的前三个月,虽然完成了业务功能开发,但是意识到自己的代码量还是远远不够,在以最快的速度完成业务功能开发后,投入leetcode的算法练习,每一道题就是一个功能的缩影,在完成300+的练习后,业务代码就得心应手了。
第二阶段: 当时培训机构的架构课对我来说,是无法转化为我的能力的,我学习它无疑是浪费我的时间,所以我更多的选择了自己去寻找资料和书籍来学习,主要是针对这几个方面,操作系统,计算机网络,netty,JVM,并发编程,框架源码,此过程历经1年。
第三阶段: 系统设计,在经历前两个阶段后, 是时候提高我的系统设计,架构能力,这个东西学不来, 靠悟,也是最难受的地方!每一次的业务功能开发前,我都会阅读软件设计方面的资料(每阅读一次,都有不一样的收获)再对业务功能进行设计,非常的耗时,但也非常的值得,经历了一年,对模块和系统的设计都有了自己的理解。
第四阶段: 和产品的深度沟通能力,无论是在需求评审,还是自己负责的模块,系统,都要和产品深度的沟通,这个阶段经历了半年,完成了这4个阶段后,自己的能力得到了极大的提高。跳槽成功
大家会说, 现在的环境不好, 不好找工作。从我自己的经历来看, 转行培训过来的同学, 开局不管怎么样, 不管遇到多大的困难,不要放弃自己, 请坚持学习, 好好的积累3年,完善自己的各项能力, 那个时候才是真正的上岸,自己也不愁找不到工作,拿到自己满意的薪资。
目前也在写重写自己的系统框架(脚手架)fd-frameworkfd-framework, 今天为了解决springmvc对请求时间进行统一格式处理 , Long精度丢失问题处理问题, 仔细花了大概5小时阅读里面的源码,期间的实现方式改了4版, 每一次的深入,都是有新的实现想法, 真的感觉很开心,哈哈, 预祝各位转行的同学上岸。
来源:juejin.cn/post/7323408577709080610
为什么 Java 大佬都不推荐使用 keySet() 遍历HashMap?
在Java编程中,HashMap
是一种非常常见的数据结构。我们经常需要对其中的键值对进行遍历。通常有多种方法可以遍历 HashMap
,其中一种方法是使用 keySet()
方法。
然而,很多Java大佬并不推荐这种方法。为什么呢?
已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。
keySet()
方法的工作原理
首先,让我们来看一下 keySet()
方法是如何工作的。keySet()
方法返回 HashMap
中所有键的集合 (Set<K>
)。然后我们可以使用这些键来获取相应的值。
代码示例如下:
// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
// 使用keySet()方法遍历HashMap
for (String key : map.keySet()) {
// 通过键获取相应的值
Integer value = map.get(key);
System.out.println("Key: " + key + ", Value: " + value);
}
这个代码看起来没什么问题,但在性能和效率上存在一些隐患。
keySet()
方法的缺点
1、 多次哈希查找:如上面的代码所示,使用 keySet()
方法遍历时,需要通过键去调用 map.get(key)
方法来获取值。这意味着每次获取值时,都需要进行一次哈希查找操作。如果 HashMap
很大,这种方法的效率就会明显降低。
2、 额外的内存消耗:keySet()
方法会生成一个包含所有键的集合。虽然这个集合是基于 HashMap
的键的视图,但仍然需要额外的内存开销来维护这个集合的结构。如果 HashMap
很大,这个内存开销也会变得显著。
3、 代码可读性和维护性:使用 keySet()
方法的代码可能会让人误解,因为它没有直接表现出键值对的关系。在大型项目中,代码的可读性和维护性尤为重要。
更好的选择:entrySet()
方法
相比之下,使用 entrySet()
方法遍历 HashMap
是一种更好的选择。entrySet()
方法返回的是 HashMap
中所有键值对的集合 (Set<Map.Entry<K, V>>
)。通过遍历这个集合,我们可以直接获取每个键值对,从而避免了多次哈希查找和额外的内存消耗。
下面是使用 entrySet()
方法的示例代码:
// 创建一个HashMap并填充数据
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
// 使用entrySet()方法遍历HashMap
for (Map.Entry<String, Integer> entry : map.entrySet()) {
// 直接获取键和值
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
entrySet()
方法的优势
1、 避免多次哈希查找:在遍历过程中,我们可以直接从 Map.Entry
对象中获取键和值,而不需要再次进行哈希查找,提高了效率。
2、 减少内存消耗:entrySet()
方法返回的是 HashMap
内部的一个视图,不需要额外的内存来存储键的集合。
3、 提高代码可读性:entrySet()
方法更直观地表现了键值对的关系,使代码更加易读和易维护。
性能比较
我们来更深入地解析性能比较,特别是 keySet()
和 entrySet()
方法在遍历 HashMap
时的性能差异。
主要性能问题
1、 多次哈希查找: 使用 keySet()
方法遍历 HashMap
时,需要通过键调用 map.get(key)
方法获取值。这意味着每次获取值时都需要进行一次哈希查找操作。哈希查找虽然时间复杂度为 O(1),但在大量数据下,频繁的哈希查找会累积较高的时间开销。
2、 额外的内存消耗: keySet()
方法返回的是一个包含所有键的集合。虽然这个集合是基于 HashMap
的键的视图,但仍然需要额外的内存来维护这个集合的结构。
更高效的选择:entrySet()
方法
相比之下,entrySet()
方法返回的是 HashMap
中所有键值对的集合 (Set<Map.Entry<K, V>>
)。通过遍历这个集合,我们可以直接获取每个键值对,避免了多次哈希查找和额外的内存消耗。
性能比较示例
让我们通过一个具体的性能比较示例来详细说明:
import java.util.HashMap;
import java.util.Map;
public class HashMapTraversalComparison {
public static void main(String[] args) {
// 创建一个大的HashMap
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}
// 测试keySet()方法的性能
long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
// 测试entrySet()方法的性能
startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
}
}
深度解析性能比较示例
1、 创建一个大的 HashMap
:
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}
- 创建一个包含100万个键值对的
HashMap
。 键
为"key" + i
,值
为i
。- 这个
HashMap
足够大,可以明显展示两种遍历方法的性能差异。
2、 测试 keySet()
方法的性能:
long startTime = System.nanoTime(); // 记录开始时间
for (String key : map.keySet()) {
Integer value = map.get(key); // 通过键获取值
}
long endTime = System.nanoTime(); // 记录结束时间
System.out.println("keySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
- 使用
keySet()
方法获取所有键,并遍历这些键。 - 在每次迭代中,通过
map.get(key)
方法获取值。 - 记录开始时间和结束时间,计算遍历所需的总时间。
3、 测试 entrySet()
方法的性能:
startTime = System.nanoTime(); // 记录开始时间
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey(); // 直接获取键
Integer value = entry.getValue(); // 直接获取值
}
endTime = System.nanoTime(); // 记录结束时间
System.out.println("entrySet() 方法遍历时间: " + (endTime - startTime) + " 纳秒");
- 使用
entrySet()
方法获取所有键值对,并遍历这些键值对。 - 在每次迭代中,直接从
Map.Entry
对象中获取键和值。 - 记录开始时间和结束时间,计算遍历所需的总时间。
性能结果分析
假设上述代码的运行结果如下:
keySet() 方法遍历时间: 1200000000 纳秒
entrySet() 方法遍历时间: 800000000 纳秒
可以看出,使用 entrySet()
方法的遍历时间明显短于 keySet()
方法。这主要是因为:
1、 避免了多次哈希查找: 使用 keySet()
方法时,每次获取值都需要进行一次哈希查找。而使用 entrySet()
方法时,键和值直接从 Map.Entry
对象中获取,无需再次查找。
2、 减少了内存消耗: 使用 keySet()
方法时,额外生成了一个包含所有键的集合。而使用 entrySet()
方法时,返回的是 HashMap
内部的一个视图,无需额外的内存开销。
小结一下
通过性能比较示例,我们可以清楚地看到 entrySet()
方法在遍历 HashMap
时的效率优势。使用 entrySet()
方法不仅能避免多次哈希查找,提高遍历效率,还能减少内存消耗。
综上所述,在遍历 HashMap
时,entrySet()
方法是更优的选择。
几种高效的替代方案
除了 entrySet()
方法外,还有其他几种高效的替代方案,可以用于遍历 HashMap
。
以下是几种常见的高效替代方案及其优缺点分析:
1. 使用 entrySet()
方法
我们已经讨论过,entrySet()
方法是遍历 HashMap
时的一个高效选择。它直接返回键值对的集合,避免了多次哈希查找,减少了内存开销。
import java.util.HashMap;
import java.util.Map;
public class EntrySetTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}
2. 使用 forEach
方法
从 Java 8 开始,Map
接口提供了 forEach
方法,可以直接对每个键值对进行操作。这种方式利用了 lambda 表达式,代码更简洁,可读性强。
import java.util.HashMap;
import java.util.Map;
public class ForEachTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
map.forEach((key, value) -> {
System.out.println("Key: " + key + ", Value: " + value);
});
}
}
3. 使用 iterator
方法
另一种遍历 HashMap
的方法是使用迭代器 (Iterator
)。这种方法适用于需要在遍历过程中对集合进行修改的情况,比如删除某些元素。
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class IteratorTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
Iterator<Map.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Integer> entry = iterator.next();
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
}
}
}
4. 使用 Streams API
Java 8 引入了 Streams API,可以结合 stream()
方法和 forEach
方法来遍历 HashMap
。这种方法可以对集合进行更复杂的操作,比如过滤、映射等。
import java.util.HashMap;
import java.util.Map;
public class StreamTraversal {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 1);
map.put("banana", 2);
map.put("cherry", 3);
map.entrySet().stream().forEach(entry -> {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println("Key: " + key + ", Value: " + value);
});
}
}
优缺点分析
entrySet()
方法:
- 优点:避免多次哈希查找,减少内存消耗,代码简单明了。
- 缺点:没有特定缺点,在大多数情况下是最佳选择。
forEach
方法:
- 优点:代码简洁,可读性强,充分利用 lambda 表达式。
- 缺点:仅适用于 Java 8 及以上版本。
iterator
方法:
- 优点:适用于需要在遍历过程中修改集合的情况,如删除元素。
- 缺点:代码稍显繁琐,不如
entrySet()
和forEach
方法直观。
Streams API
方法:
- 优点:支持复杂操作,如过滤、映射等,代码简洁。
- 缺点:仅适用于 Java 8 及以上版本,性能在某些情况下可能不如
entrySet()
和forEach
。
结论
在遍历 HashMap
时,entrySet()
方法是一个高效且广泛推荐的选择。对于更现代的代码风格,forEach
方法和 Streams API 提供了简洁且强大的遍历方式。如果需要在遍历过程中修改集合,可以使用 iterator
方法。根据具体需求选择合适的遍历方法,可以显著提高代码的效率和可读性。
已收录于,我的技术网站:ddkk.com 里面有,500套技术系列教程、1万+道,面试八股文、BAT面试真题、简历模版,工作经验分享、架构师成长之路,等等什么都有,欢迎收藏和转发。
来源:juejin.cn/post/7393663398406799372
threejs 仿抖音漂移停车特效
最近刷到了抖音的漂移停车2的视频,感觉还蛮有趣的
乍一看,实现这个漂移停车的效果需要一些东西:
- 一辆一直往前开的小车和一个停车点,这里就做成一个小车库吧
- 漂移停车逻辑。这个小游戏是通过往左往右触屏滑动来刹车,附带了转向
- 和车库的碰撞处理
- 停车后的计分逻辑
之前的文章实现了基于threejs的3d场景和一辆麻雀虽小五脏俱全的小车,咱们拿来接着用一下
行车物理模拟
其实之前自己实现的自车行驶超级简单,加减速、转弯都做的比较粗糙,这里引入物理库 cannon-es(cannon.js 的增强版)来帮忙做这块逻辑。物理库的作用其实就是模拟一些真实的物理效果,比如行车、物理碰撞、重力等。具体api文档 戳这里,不过只有英文文档
npm install cannon-es
先初始化一个物理世界,其实和 threejs 场景的初始化有点像,之后也是需要将物理世界的物体和 threejs 的物体一一对应地关联起来,比如这里的地面、小车和车库,这样后面物理库做计算后,再将作用后的物体的位置信息赋值到 threejs 对应物体的属性上,最后通过循环渲染(animate
)就能模拟行车场景了
import * as CANNON from "cannon-es";
// ...
const world = new CANNON.World();
// 物理世界预处理,这个可以快速排除明显不发生碰撞的物体对,提高模拟效率
world.broadphase = new CANNON.SAPBroadphase(world);
// 物理世界的重力向量
world.gravity.set(0, -9.8, 0);
// 刚体之间接触面的默认摩擦系数
world.defaultContactMaterial.friction = 0;
小车对象
cannon-es
的 RaycastVehicle 类可以辅助我们管理物理世界的小车对象,它提供了很多蛮好用的api,不仅可以帮助我们更好地管理车轮,而且能很好地根据地形运动
物理世界物体的基本要素有形状(常见的有Box长方体/Plane平面/Sphere球体
)、材质 Material
和刚体 Body
,类比 threejs 中的几何体、材质和 Mesh
。创建刚体后别忘了将它添加到物理世界里,和 threejs 将物体添加到 scene 场景里类似
// 创建小车底盘形状,这里就是一个长方体
const chassisShape = new CANNON.Box(new CANNON.Vec3(1, 0.3, 2));
// 创建质量为150kg的小车刚体。物理世界的质量单位是kg
const chassisBody = new CANNON.Body({ mass: 150 });
// 关联刚体和形状
chassisBody.addShape(chassisShape);
// 设定刚体位置
chassisBody.position.set(0, 0.4, 0);
// 基于小车底盘创建小车对象
const vehicle = new CANNON.RaycastVehicle({
chassisBody,
// 定义车辆的方向轴(0:x轴,1:y轴,2:z轴),让它符合右手坐标系
// 车辆右侧
indexRightAxis: 0,
// 车辆上方
indexUpAxis: 1,
// 车辆前进方向
indexForwardAxis: 2,
});
// 将小车添加到物理世界里,类比 threejs 的 scene.add()
vehicle.addToWorld(world);
四个车轮
接下来定义下车轮对象,用到了 Cylinder
这种圆柱体的形状,然后要注意做好旋转值 Quaternion
的调整。这部分会稍微复杂些,可以耐心看下注释:
// 车轮配置,详情配置参考 https://pmndrs.github.io/cannon-es/docs/classes/RaycastVehicle.html#addWheel
const options = {
radius: 0.4, // 轮子半径
directionLocal: new CANNON.Vec3(0, -1, 0), // 轮子方向向量,指轮子从中心点出发的旋转方向
suspensionStiffness: 45,
suspensionRestLength: 0.4,
frictionSlip: 5, // 滑动摩擦系数
dampingRelaxation: 2.3,
dampingCompression: 4.5,
maxSuspensionForce: 200000,
rollInfluence: 0.01,
axleLocal: new CANNON.Vec3(-1, 0, 0),
chassisConnectionPointLocal: new CANNON.Vec3(1, 1, 0),
maxSuspensionTravel: 0.25,
customSlidingRotationalSpeed: -30,
useCustomSlidingRotationalSpeed: true,
};
const axlewidth = 0.7;
// 设置第一个车轮的连接点位置
options.chassisConnectionPointLocal.set(axlewidth, 0, -1);
// 按指定配置给小车添加第一个车轮,其他车轮类似
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, -1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(axlewidth, 0, 1);
vehicle.addWheel(options);
options.chassisConnectionPointLocal.set(-axlewidth, 0, 1);
vehicle.addWheel(options);
// 四个车轮
const wheelBodies: CANNON.Body[] = [];
const wheelVisuals: THREE.Mesh[] = [];
vehicle.wheelInfos.forEach(function (wheel) {
const shape = new CANNON.Cylinder(
wheel.radius,
wheel.radius,
wheel.radius / 2,
20
);
const body = new CANNON.Body({ mass: 1, material: wheelMaterial });
// 刚体可以是动态(DYNAMIC)、静态(STATIC)或运动学(KINEMATIC)
body.type = CANNON.Body.KINEMATIC;
// 0表示这个刚体将与所有其他未设置特定过滤组的刚体进行碰撞检测
body.collisionFilterGr0up = 0;
// 使用setFromEuler方法将欧拉角转换为四元数,欧拉角的值为-Math.PI / 2(即-90度或-π/2弧度)
const quaternion = new CANNON.Quaternion().setFromEuler(
-Math.PI / 2,
0,
0
);
body.addShape(shape, new CANNON.Vec3(), quaternion);
wheelBodies.push(body);
// 创建3d世界的车轮对象
const geometry = new THREE.CylinderGeometry(
wheel.radius,
wheel.radius,
0.4,
32
);
const material = new THREE.MeshPhongMaterial({
color: 0xd0901d,
emissive: 0xaa0000,
flatShading: true,
side: THREE.DoubleSide,
});
const cylinder = new THREE.Mesh(geometry, material);
cylinder.geometry.rotateZ(Math.PI / 2);
wheelVisuals.push(cylinder);
scene.add(cylinder);
});
这一步很关键,需要在每次物理模拟计算结束后 (postStep
事件的回调函数) 更新车轮的位置和转角
// ...
world.addEventListener("postStep", function () {
for (let i = 0; i < vehicle.wheelInfos.length; i++) {
vehicle.updateWheelTransform(i);
const t = vehicle.wheelInfos[i].worldTransform;
// 更新物理世界车轮对象的属性
wheelBodies[i].position.copy(t.position);
wheelBodies[i].quaternion.copy(t.quaternion);
// 更新3d世界车轮对象的属性
wheelVisuals[i].position.copy(t.position);
wheelVisuals[i].quaternion.copy(t.quaternion);
}
});
车辆行驶和转向
监听键盘事件,按下上下方向键给一个前后的引擎动力,按下左右方向键给车轮一个转角值
// 引擎动力值
const engineForce = 3000;
// 转角值
const maxSteerVal = 0.7;
// 刹车作用力
const brakeForce = 20;
// ...
// 刹车
function brakeVehicle() {
// 四个车轮全部加刹车作用力
vehicle.setBrake(brakeForce, 0);
vehicle.setBrake(brakeForce, 1);
vehicle.setBrake(brakeForce, 2);
vehicle.setBrake(brakeForce, 3);
}
function handleNavigate(e: any) {
if (e.type != "keydown" && e.type != "keyup") {
return;
}
const isKeyup = e.type === "keyup";
switch (e.key) {
case "ArrowUp":
// 给第2/3个车轮加引擎动力
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : engineForce, 3);
break;
case "ArrowDown":
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 2);
vehicle.applyEngineForce(isKeyup ? 0 : -engineForce, 3);
break;
case "ArrowLeft":
// 设置车轮转角
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : -maxSteerVal, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(isKeyup ? 0 : maxSteerVal, 3);
break;
}
brakeVehicle();
}
window.addEventListener("keydown", handleNavigate);
window.addEventListener("keyup", handleNavigate);
然后在每一帧里重新计算物体的物理值,并赋值给 3d 世界的小车属性,就可以实现行车效果
function updatePhysics() {
world.step(1 / 60);
egoCar.position.copy(chassisBody.position);
egoCar.quaternion.copy(chassisBody.quaternion);
}
// ...
const animate = () => {
stats.begin();
// ...
updatePhysics();
// ...
stats.end();
requestAnimationFrame(animate);
};
animate();
地面优化
地面看起来太光滑,显得有点假,咱们先给地面加上有磨砂质感的纹理贴图,同时隐藏掉辅助网格
// ...
// 加载纹理贴图
textureLoader.load("/gta/floor.jpg", (texture) => {
const planeMaterial = new THREE.MeshLambertMaterial({
// 将贴图对象赋值给材质
map: texture,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(planeGeometry, planeMaterial);
// 地面接受阴影
plane.receiveShadow = true;
plane.rotation.x = Math.PI / 2;
scene.add(plane);
});
加载完贴图,生成3d场景的地面对象后,别忘了创建地面刚体并关联。这里还要定义地面刚体的物理材质,类比 threejs 的材质,会影响不同刚体之间摩擦和反弹的效果
// ...
// 定义地板的物理材质
const groundMaterial = new CANNON.Material("groundMaterial");
// 定义车轮的物理材质,其实之前代码用过了,可以留意下
const wheelMaterial = new CANNON.Material("wheelMaterial");
// 定义车轮和地板之间接触面的物理关联,在这里定义摩擦反弹等系数
const wheelGroundContactMaterial = new CANNON.ContactMaterial(
wheelMaterial,
groundMaterial,
{
// 摩擦系数
friction: 0.5,
// 反弹系数,0表示没有反弹
restitution: 0,
}
);
world.addContactMaterial(wheelGroundContactMaterial);
// ...
textureLoader.load("/gta/floor.jpg", (texture) => {
// ...
// 地面刚体
const q = plane.quaternion;
const planeBody = new CANNON.Body({
// 0说明物体是静止的,发生物理碰撞时不会相互移动
mass: 0,
// 应用接触面材质
material: groundMaterial,
shape: new CANNON.Plane(),
// 和3d场景的旋转值保持一致。在Cannon.js中,刚体的旋转可以通过四元数来表示,而不是传统的欧拉角或轴角表示法
quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w),
});
world.addBody(planeBody);
});
这回开起来可顺畅许多了,场景和自车旋转也变得更自然一些,感谢开源 ~
搭建车库
咱就搭个棚,一个背景墙、两个侧边墙、加一个屋顶和地板,其实都是些立方体,拼装成网格对象 Mesh 后,按照一定的位置和旋转拼在一起组成小车库,参考代码:
createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
const sider1 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider1.rotation.y = Math.PI / 2;
sider1.position.set(-1.5, 0.1, -50);
this.scene.add(sider1);
const sider2 = new THREE.Mesh(
new THREE.BoxGeometry(6, 4, 0.3),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
sider2.rotation.y = Math.PI / 2;
sider2.position.set(1.5, 0.1, -50);
this.scene.add(sider2);
// 创建屋顶
const roof = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({
color: 0xcccccc,
// 注意:这个值不为true的话,设置opacity是没用的
transparent: true,
opacity: 0.8,
})
);
roof.rotation.x = Math.PI / 2;
roof.position.set(0, 2, -50);
this.scene.add(roof);
// 创建地板
const floor = new THREE.Mesh(
new THREE.BoxGeometry(3, 6, 0.1),
new THREE.MeshBasicMaterial({ color: 0x666666 })
);
floor.rotation.x = Math.PI / 2;
floor.position.set(0, 0.1, -50);
this.scene.add(floor);
}
好了,一个稍微有点模样的小车库就大功告成
创建车库刚体
先加个背景墙的物理刚体
createParkingHouse() {
if (!this.scene || !this.world) return;
// 创建背景墙
const background = new THREE.Mesh(
new THREE.BoxGeometry(3, 4, 0.1),
new THREE.MeshBasicMaterial({ color: 0xcccccc })
);
background.position.set(0, 0, -53);
this.scene.add(background);
// 创建侧墙
// ...
// physic
const houseShape = new CANNON.Box(new CANNON.Vec3(1.5, 4, 0.1));
const houseBody = new CANNON.Body({ mass: 0 });
houseBody.addShape(houseShape);
houseBody.position.set(0, 0, -53);
this.world.addBody(houseBody);
}
// ...
其他的墙体类似的处理,屋顶先不管吧,小车应该也够不着。来,先撞一下试试
漂移停车
其实达到一定速度,通过方向键就能做一个甩尾漂移倒车入库
- 提供一个弹射的初始动力
// ...
animate();
setTimeout(() => {
// 给后轮上点动力
vehicle.applyEngineForce(2000, 2);
vehicle.applyEngineForce(2000, 3);
}, 100);
- 电脑端根据方向键触发漂移,这里注意要消除后轮的动力
// ...
case "ArrowLeft":
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : -maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
case "ArrowRight":
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 2);
vehicle.setSteeringValue(keyup ? 0 : maxSteerVal, 3);
// 漂移停车游戏需要消除后轮动力,如果要正常行驶,需要去掉下面俩行
vehicle.applyEngineForce(0, 2);
vehicle.applyEngineForce(0, 3);
break;
// ...
- 移动端根据触屏方向触发。需要注意此时要把相机控制器关掉,避免和触屏操作冲突。计算触发方向的逻辑参考
// 计算划过的角度
function getAngle(angx: number, angy: number) {
return (Math.atan2(angy, angx) * 180) / Math.PI;
}
// 计算触屏方向
function getDirection(
startx: number,
starty: number,
endx: number,
endy: number
): ESlideDirection {
const angx = endx - startx;
const angy = endy - starty;
let result = ESlideDirection.;
if (Math.abs(angx) < 2 && Math.abs(angy) < 2) {
return result;
}
const angle = getAngle(angx, angy);
if (angle >= -135 && angle <= -45) {
result = ESlideDirection.Top;
} else if (angle > 45 && angle < 135) {
result = ESlideDirection.Bottom;
} else if (
(angle >= 135 && angle <= 180) ||
(angle >= -180 && angle < -135)
) {
result = ESlideDirection.Left;
} else if (angle >= -45 && angle <= 45) {
result = ESlideDirection.Right;
}
return result;
}
let startx = 0;
let starty = 0;
document.addEventListener("touchstart", (e) => {
startx = e.touches[0].pageX;
starty = e.touches[0].pageY;
});
document.addEventListener("touchend", function (e) {
const endx = e.changedTouches[0].pageX;
const endy = e.changedTouches[0].pageY;
const direction = getDirection(startx, starty, endx, endy);
// 根据方向做转向和刹车的处理,和上面电脑侧左右键的逻辑一致就行了
// ...
})
计算分数
根据小车和车库角度偏差和中心点偏差来综合得分,这里就不细究了,浅浅定个规则:
- 不入库或没倒车:0分
- 其他情况:50分 + 角度分(20比例) + 中心分(30比例)
车停住后,先算出分数,再加个数字递增的效果,用 setInterval
实现就好了。不过这里要注意用回调函数的方式更新 state 值,避免闭包引起值不更新的问题
计分组件实现代码参考:
export const Overlay = observer(() => {
const [score, setScore] = useState(0);
useEffect(() => {
if (vehicleStore.score) {
// 计分动画
const timer = setInterval(() => {
// 回调方式更新state
setScore((score) => {
if (score + 1 === vehicleStore.score) {
clearInterval(timer);
}
return score + 1;
});
}, 10);
}
}, [vehicleStore.score]);
if (!vehicleStore.isStop) {
return null;
}
return (
<div className={styles["container"]}>
<div className={styles["score-box"]}>
<div className={styles["score-desc"]}>得分div>
<div>{score}div>
div>
div>
);
});
那么问题来了,怎么监听它停下了?可以加一个速度的阈值 velocityThreshold
,如果小车刚体的速度低于这个阈值就判定小车停下了。然后通过 mobx 状态库建立一个 vehicleStore
,主要是维护 isStop
(是否停止) 和 score
(分数) 这两个变量,变化后自动通知计分组件更新,这部分逻辑可以参考源码实现 ~
// ...
const velocityThreshold = 0.01;
function updatePhysics() {
world.step(1 / 60);
// ...
// 检查刚体的速度,小于阈值视为停止
if (
chassisBody.velocity.length() < velocityThreshold &&
// 停车标识
!vehicleStore.isStop
) {
console.log("小车已经停止");
vehicleStore.stop();
// 触发计分逻辑,自行参考源码
// ...
vehicleStore.setScore(score);
}
}
// ...
传送门
来源:juejin.cn/post/7331070678693380122
2024年的安卓现代开发
大家好 👋🏻, 新的一年即将开始, 我不想错过这个机会, 与大家分享一篇题为《2024年的安卓现代开发》的文章. 🚀
如果你错过了我的上一篇文章, 可以看看2023年的安卓现代开发并回顾一下今年的变化.
免责声明
📝 本文反映了我的个人观点和专业见解, 并参考了 Android 开发者社区中的不同观点. 此外, 我还定期查看 Google 为 Android 提供的指南.
🚨 需要强调的是, 虽然我可能没有明确提及某些引人注目的工具, 模式和体系结构, 但这并不影响它们作为开发 Android 应用的宝贵替代方案的潜力.
Kotlin 无处不在 ❤️
Kotlin是由JetBrains开发的一种编程语言. 由谷歌推荐, 谷歌于 2017 年 5 月正式发布(查看这里). 它是一种与 Java 兼容的现代编程语言, 可以在 JVM 上运行, 这使得它在 Android 应用开发中的采用非常迅速.
无论你是不是安卓新手, 都应该把 Kotlin 作为首选, 不要逆流而上 🏊🏻 😎, 谷歌在 2019 年谷歌 I/O 大会上宣布了这一做法. 有了 Kotlin, 你就能使用现代语言的所有功能, 包括协程的强大功能和使用为 Android 生态系统开发的现代库.
请查看Kotlin 官方文档
Kotlin 是一门多用途语言, 我们不仅可以用它来开发 Android 应用, 尽管它的流行很大程度上是由于 Android 应用, 我们可以从下图中看到这一点.
KotlinConf ‘23
Kotlin 2.0 要来了
另一个需要强调的重要事件是Kotlin 2.0
的发布, 它近在眼前. 截至本文发稿之日, 其版本为 2.0.0-beta4
新的 K2 编译器 也是 Kotlin 2.0 的另一个新增功能, 它将带来显著的性能提升, 加速新语言特性的开发, 统一 Kotlin 支持的所有平台, 并为多平台项目提供更好的架构.
请查看 KotlinConf '23 的回顾, 你可以找到更多信息.
Compose 🚀
Jetpack Compose 是 Android 推荐的用于构建本地 UI 的现代工具包. 它简化并加速了 Android 上的 UI 开发. 通过更少的代码, 强大的工具和直观的 Kotlin API, 快速实现你的应用.
Jetpack Compose 是 Android Jetpack 库的一部分, 使用 Kotlin 编程语言轻松创建本地UI. 此外, 它还与 LiveData 和 ViewModel 等其他 Android Jetpack 库集成, 使得构建具有强反应性和可维护性的 Android 应用变得更加容易.
Jetpack Compose 的一些主要功能包括
- 声明式UI
- 可定制的小部件
- 与现有代码(旧视图系统)轻松集成
- 实时预览
- 改进的性能.
资源:
- Jetpack Compose 文档
- Compose 与 Kotlin 的兼容性图谱
- Jetpack Compose 路线图
- 课程
- Jetpack Compose 中
@Composable
的API 指南
Android Jetpack ⚙️
Jetpack是一套帮助开发者遵循最佳实践, 减少模板代码, 编写在不同Android版本和设备上一致运行的代码的库, 这样开发者就可以专注于他们的业务代码.
其中最常用的工具有:
Material You / Material Design 🥰
Material You 是在 Android 12 中引入并在 Material Design 3 中实现的一项新的定制功能, 它使用户能够根据个人喜好定制操作系统的视觉外观. Material Design 是一个由指南, 组件和工具组成的可调整系统, 旨在坚持UI设计的最高标准. 在开源代码的支持下, Material Design 促进了设计师和开发人员之间的无缝协作, 使团队能够高效地创造出令人惊叹的产品.
目前, Material Design 的最新版本是 3, 你可以在这里查看更多信息. 此外, 你还可以利用Material Theme Builder来帮助你定义应用的主题.
代码仓库
SplashScreen API
Android 中的SplashScreen API对于确保应用在 Android 12 及更高版本上正确显示至关重要. 不更新会影响应用的启动体验. 为了在最新版本的操作系统上获得一致的用户体验, 快速采用该 API 至关重要.
Clean架构
Clean架构
的概念是由Robert C. Martin提出的. 它的基础是通过将软件划分为不同层次来实现责任分离.
特点
- 独立于框架.
- 可测试.
- 独立于UI
- 独立于数据库
- 独立于任何外部机构.
依赖规则
作者在他的博文Clean代码中很好地描述了依赖规则.
依赖规则是使这一架构得以运行的首要规则. 这条规则规定, 源代码的依赖关系只能指向内部. 内圈中的任何东西都不能知道外圈中的任何东西. 特别是, 外圈中声明的东西的名称不能被内圈中的代码提及. 这包括函数, 类, 变量或任何其他命名的软件实体.
- 博文Clean代码
安卓中的Clean架构
:
Presentation 层: Activitiy, Composable, Fragment, ViewModel和其他视图组件.
Domain层: 用例, 实体, 仓库接口和其他Domain组件.
Data层: 仓库的实现类, Mapper, DTO 等.
Presentation层的架构模式
架构模式是一种更高层次的策略, 旨在帮助设计软件架构, 其特点是在一个可重复使用的框架内为常见的架构问题提供解决方案. 架构模式与设计模式类似, 但它们的规模更大, 解决的问题也更全面, 如系统的整体结构, 组件之间的关系以及数据管理的方式等.
在Presentation层中, 我们有一些架构模式, 我想重点介绍以下几种:
- MVVM
- MVI
我不想逐一解释, 因为在互联网上你可以找到太多相关信息. 😅
此外, 你还可以查看应用架构指南.
依赖注入
依赖注入是一种软件设计模式, 它允许客户端从外部获取依赖关系, 而不是自己创建依赖关系. 它是一种在对象及其依赖关系之间实现控制反转(IoC)的技术.
模块化
模块化是一种软件设计技术, 可将应用划分为独立的模块, 每个模块都有自己的功能和职责.
模块化的优势
可重用性: 拥有独立的模块, 就可以在应用的不同部分甚至其他应用中重复使用.
严格的可见性控制: 模块可以让你轻松控制向代码库其他部分公开的内容.
自定义交付: Play特性交付 使用应用Bundle的高级功能, 允许你有条件或按需交付应用的某些功能.
可扩展性: 通过独立模块, 可以添加或删除功能, 而不会影响应用的其他部分.
易于维护: 将应用划分为独立的模块, 每个模块都有自己的功能和责任, 这样就更容易理解和维护代码.
易于测试: 有了独立的模块, 就可以对它们进行隔离测试, 从而便于发现和修复错误.
改进架构: 模块化有助于改进应用的架构, 从而更好地组织和结构代码.
改善协作: 通过独立的模块, 开发人员可以不受干扰地同时开发应用的不同部分.
构建时间: 一些 Gradle 功能, 如增量构建, 构建缓存或并行构建, 可以利用模块化提高构建性能.
更多信息请参阅官方文档.
网络
序列化
在本节中, 我想提及两个我认为非常重要的工具: 与 Retrofit 广泛结合使用的Moshi, 以及 JetBrains 的 Kotlin 团队押宝的Kotlin Serialization.
Moshi 和 Kotlin Serialization 是用于 Kotlin/Java 的两个序列化/反序列化库, 可将对象转换为 JSON 或其他序列化格式, 反之亦然. 这两个库都提供了友好的接口, 并针对移动和桌面应用进行了优化. Moshi 主要侧重于 JSON 序列化, 而 Kotlin Serialization 则支持包括 JSON 在内的多种序列化格式.
图像加载
要从互联网上加载图片, 有几个第三方库可以帮助你处理这一过程. 图片加载库为你做了很多繁重的工作; 它们既处理缓存(这样你就不用多次下载图片), 也处理下载图片并将其显示在屏幕上的网络逻辑.
_ 官方安卓文档
响应/线程管理
说到响应式编程和异步进程, Kotlin协程凭借其suspend函数和Flow脱颖而出. 然而, 在 Android 应用开发中, 承认 RxJava 的价值也是至关重要的. 尽管协程和 Flow 的采用率越来越高, 但 RxJava 仍然是多个项目中稳健而受欢迎的选择.
对于新项目, 请始终选择Kotlin协程
❤️. 可以在这里探索一些Kotlin协程相关的概念.
本地存储
在构建移动应用时, 重要的一点是能够在本地持久化数据, 例如一些会话数据或缓存数据等. 根据应用的需求选择合适的存储选项非常重要. 我们可以存储键值等非结构化数据, 也可以存储数据库等结构化数据. 请记住, 这一点并没有提到我们可用的所有本地存储类型(如文件存储), 只是提到了允许我们保存数据的工具.
建议:
- S̶h̶a̶r̶e̶d̶P̶r̶e̶f̶e̶r̶e̶n̶c̶e̶s̶
- DataStore
- EncryptedSharedPreferences
测试 🕵🏼
软件开发中的测试对于确保产品质量至关重要. 它能检测错误, 验证需求并确保客户满意. 下面是一些最常用的测试工具::
截屏测试 📸
Android 中的截屏测试涉及自动捕获应用中各种 UI 元素的屏幕截图, 并将其与基线图像进行比较, 以检测任何意外的视觉变化. 它有助于确保不同版本和配置的应用具有一致的UI外观, 并在开发过程的早期捕捉视觉回归.
R8 优化
R8 是默认的编译器, 可将项目的 Java 字节码转换为可在 Android 平台上运行的 DEX 格式. 通过缩短类名及其属性, 它可以帮助我们混淆和减少应用的代码, 从而消除项目中未使用的代码和资源. 要了解更多信息, 请查看有关 缩减, 混淆和优化应用 的 Android 文档. 此外, 你还可以通过ProGuard
规则文件禁用某些任务或自定义 R8 的行为.
- 代码缩减
- 缩减资源
- 混淆
- 优化
第三方工具
- DexGuard
Play 特性交付
Google Play 的应用服务模式称为动态交付, 它使用 Android 应用Bundle为每个用户的设备配置生成并提供优化的 APK, 因此用户只需下载运行应用所需的代码和资源.
自适应布局
随着具有不同外形尺寸的移动设备使用量的增长, 我们需要一些工具, 使我们的 Android 应用能够适应不同类型的屏幕. 因此, Android 为我们提供了Window Size Class, 简单地说, 就是三大类屏幕格式, 它们是我们开发设计的关键点. 这样, 我们就可以避免考虑许多屏幕设计的复杂性, 从而将可能性减少到三组, 它们是 Compat, Medium和Expanded.
Window Size Class
我们拥有的另一个重要资源是Canonical Layout, 它是预定义的屏幕设计, 可用于 Android 应用中的大多数场景, 还为我们提供了如何将其适用于大屏幕的指南.
其他相关资源
本地化 🌎
本地化包括调整产品以满足不同地区不同受众的需求. 这包括翻译文本, 调整格式和考虑文化因素. 其优势包括进入全球市场, 增强用户体验, 提高客户满意度, 增强在全球市场的竞争力以及遵守当地法规.
注: BCP 47 是安卓系统使用的国际化标准.
参考资料
性能 🔋⚙️
在开发 Android 应用时, 我们必须确保用户体验更好, 这不仅体现在应用的开始阶段, 还体现在整个执行过程中. 因此, 我们必须使用一些工具, 对可能影响应用性能的情况进行预防性分析和持续监控:
应用内更新
当你的用户在他们的设备上不断更新你的应用时, 他们可以尝试新功能, 并从性能改进和错误修复中获益. 虽然有些用户会在设备连接到未计量的连接时启用后台更新, 但其他用户可能需要提醒才能安装更新. 应用内更新是 Google Play 核心库的一项功能, 可提示活跃用户更新你的应用*.
运行 Android 5.0(API 等级 21)或更高版本的设备支持应用内更新功能. 此外, 应用内更新仅支持 Android 移动设备, Android 平板电脑和 Chrome OS 设备.
- 应用内更新文档
应用内评论
Google Play 应用内评论 API 可让你提示用户提交 Play Store 评级和评论, 而无需离开你的应用或游戏.
一般来说, 应用内评论流程可在用户使用应用的整个过程中随时触发. 在流程中, 用户可以使用 1-5 星系统对你的应用进行评分, 并添加可选评论. 一旦提交, 评论将被发送到 Play Store 并最终显示出来.
*为保护用户隐私并避免 API 被滥用, 你的应用应严格遵守有关何时请求应用内评论和评论提示的设计的规定.
- 应用内评论文档
可观察性 👀
在竞争日益激烈的应用生态系统中, 要获得良好的用户体验, 首先要确保应用没有错误. 确保应用无错误的最佳方法之一是在问题出现时立即发现并知道如何着手解决. 使用 Android Vitals 来确定应用中崩溃和响应速度问题最多的区域. 然后, 利用 Firebase Crashlytics 中的自定义崩溃报告获取更多有关根本原因的详细信息, 以便有效地排除故障.
工具
辅助功能
辅助功能是软件设计和构建中的一项重要功能, 除了改善用户体验外, 还能让有辅助功能需求的人使用应用. 这一概念旨在改善的不能包括: 有视力问题, 色盲, 听力问题, 灵敏性问题和认知障碍等.
考虑因素:
- 增加文字的可视性(颜色对比度, 可调整文字大小)
- 使用大而简单的控件
- 描述每个UI元素
更多详情请查看辅助功能 - Android 文档
安全性 🔐
在开发保护设备完整性, 数据安全性和用户信任的应用时, 安全性是我们必须考虑的一个方面, 甚至是最重要的方面.
- 使用凭证管理器登录用户: 凭据管理器 是一个 Jetpack API, 在一个单一的 API 中支持多种登录方法, 如用户名和密码, 密码匙和联合登录解决方案(如谷歌登录), 从而简化了开发人员的集成.
- 加密敏感数据和文件: 使用EncryptedSharedPreferences 和EncryptedFile.
- 应用基于签名的权限: 在你可控制的应用之间共享数据时, 使用基于签名的权限.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapp">
<permission android:name="my_custom_permission_name"
android:protectionLevel="signature" />
- 不要将密钥, 令牌或应用配置所需的敏感数据直接放在项目库中的文件或类中. 请使用
local.properties
. - 实施 SSL Pinning: 使用 SSL Pinning 进一步确保应用与远程服务器之间的通信安全. 这有助于防止中间人攻击, 并确保只与拥有特定 SSL 证书的受信任服务器进行通信.
res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config>
<domain includeSubdomains="true">example.com</domain>
<pin-set expiration="2018-01-01">
<pin digest="SHA-256">ReplaceWithYourPin</pin>
<!-- backup pin -->
<pin digest="SHA-256">ReplaceWithYourPin</pin>
</pin-set>
</domain-config>
</network-security-config>
- 实施运行时应用自我保护(RASP): 这是一种在运行时保护应用免受攻击和漏洞的安全技术. RASP 的工作原理是监控应用的行为, 并检测可能预示攻击的可疑活动:
- 代码混淆.
- 根检测.
- 篡改/应用钩子检测.
- 防止逆向工程攻击.
- 反调试技术.
- 虚拟环境检测
- 应用行为的运行时分析.
想了解更多信息, 请查看安卓应用中的运行时应用自我保护技术(RASP). 还有一些 Android 安全指南.
版本目录
Gradle 提供了一种集中管理项目依赖关系的标准方式, 叫做版本目录; 它在 7.0 版中被试验性地引入, 并在 7.4 版中正式发布.
优点:
- 对于每个目录, Gradle 都会生成类型安全的访问器, 这样你就可以在 IDE 中通过自动补全轻松添加依赖关系.
- 每个目录对构建过程中的所有项目都是可见的. 它是声明依赖项版本的中心位置, 并确保该版本的变更适用于每个子项目.
- 目录可以声明 dependency bundles, 即通常一起使用的 "依赖关系组".
- 目录可以将依赖项的组和名称与其实际版本分开, 而使用版本引用, 这样就可以在多个依赖项之间共享一个版本声明.
请查看更多信息
Secret Gradle 插件
Google 强烈建议不要将 API key 输入版本控制系统. 相反, 你应该将其存储在本地的 secrets.properties
文件中, 该文件位于项目的根目录下, 但不在版本控制范围内, 然后使用 Secrets Gradle Plugin for Android 来读取 API 密钥.
日志
日志是一种软件工具, 用于记录程序的执行信息, 重要事件, 错误调试信息以及其他有助于诊断问题或了解程序运行情况的信息. 日志可配置为将信息写入不同位置, 如日志文件, 控制台, 数据库, 或将信息发送到日志记录服务器.
Linter / 静态代码分析器
Linter 是一种编程工具, 用于分析程序源代码, 查找代码中的潜在问题或错误. 这些问题可能是语法问题, 代码风格不当, 缺乏文档, 安全问题等, 它们会对代码的质量和可维护性产生影响.
Google Play Instant
Google Play Instant 使本地应用和游戏无需安装即可在运行 Android 5.0(API 等级 21)或更高版本的设备上启动. 你可以使用 Android Studio 构建这类体验, 称为即时应用和即时游戏. 通过允许用户运行即时应用或即时游戏(即提供即时体验), 你可以提高应用或游戏的发现率, 从而有助于吸引更多活跃用户或安装.
新设计中心
安卓团队提供了一个新的设计中心, 帮助创建美观, 现代的安卓应用, 这是一个集中的地方, 可以了解安卓在多种形态因素方面的设计.
点击查看新的设计中心
人工智能
Gemini
和PalM 2
是谷歌开发的两个最先进的人工智能(AI)模型, 它们将改变安卓应用开发的格局. 这些模型具有一系列优势, 将推动应用的效率, 用户体验和创新.
人工智能编码助手工具
Studio Bot
Studio Bot 是你的 Android 开发编码助手. 它是 Android Studio 中的一种对话体验, 通过回答 Android 开发问题帮助你提高工作效率. 它采用人工智能技术, 能够理解自然语言, 因此你可以用简单的英语提出开发问题. Studio Bot 可以帮助 Android 开发人员生成代码, 查找相关资源, 学习最佳实践并节省时间.
Github Copilot
GitHub Copilot 是一个人工智能配对程序员. 你可以使用 GitHub Copilot 直接在编辑器中获取整行或整个函数的建议.
Amazon CodeWhisperer
这是亚马逊的一项服务, 可根据你当前代码的上下文生成代码建议. 它能帮助你编写更高效, 更安全的代码, 并发现新的 API 和工具.
Kotlin Multiplatform 🖥 📱⌚️ 🥰 🚀
最后, 同样重要的是, Kotlin Multiplatform 🎉 是本年度最引人注目的新产品. 它是跨平台应用开发领域的有力竞争者. 虽然我们的主要重点可能是 Android 应用开发, 但 Kotlin Multiplatform 为我们提供了利用 KMP 框架制作完全本地 Android 应用的灵活性. 这一战略举措不仅为我们的项目提供了未来保障, 还为我们提供了必要的基础架构, 以便在我们选择多平台环境时实现无缝过渡. 🚀
如果你想深入了解 Kotlin Multiplatform, 我想与你分享这几篇文章. 在这些文章中, 探讨了这项技术的现状及其对现代软件开发的影响:
来源:juejin.cn/post/7342861726000791603