清除 GitHub 上的幽灵通知

最近我的 GitHub 页面右上角一直有个小蓝点,就像这样: 这是有未读通知的指示,但点进去却什么也看不到。 这种「幽灵通知」已经干扰了我的正常使用体验。这天实在忍无可忍,正打算给 GitHub 提交一个工单时,在官方开设的讨论区里发现了一个讨论贴,我在里面找到了有效的临时解决办法,特此记录下来以备后用。 讨论贴链接:https://github.com/orgs/community/discussions/6874 方案一 我使用的是讨论里提到的 利用浏览器开发者工具的解决方案。 在浏览器打开通知列表页面,点击左侧标记有数字的 Filters 或者 Repositories,比如我上文贴的图里的 Participating、Mentioned,或者 outcaster552/gitcoinpromosender、gitcionoda/org、yycombinator/-co、paradigm-ventures/paradigm 等等。 打开浏览器的开发者工具(F12),切换到 Console 标签页,粘贴以下代码并回车执行: document.querySelector('.js-notifications-mark-all-actions').removeAttribute('hidden'); document.querySelector('.js-notifications-mark-all-actions form[action="/notifications/beta/archive"] button').removeAttribute('disabled'); 这时页面上会出现一个 Done 按钮,点击它即可清除对应的通知。 方案二 如果以上办法没有解决问题,还可以试一下贴子里 被标记为答案的回复 是一个借助 curl 命令的解决方案。 curl -X PUT -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token $TOKEN" \ https://api.github.com/notifications \ -d '{"last_read_at":"2025-10-15T10:00:00Z"}' 其中 $TOKEN 需要替换成你自己的 GitHub 个人访问令牌(Personal Access Token),可以在 https://github.com/settings/tokens/new 创建,注意 Select scopes 里需要勾选 notifications。命令里的 last_read_at 字段的值可以按需修改为当前时间。 结语 这种幽灵通知,看情况应该来自于某些居心不良的开发者,在他们的仓库里恶意 at 大量的用户来引流,然后这些用户就会收到通知。但如果这些仓库后来因为违规被删除,那么这些通知就会变成幽灵通知,无法被正常清除。 看讨论的时间线,这个问题已经存在至少四年了,GitHub 官方似乎并没有打算修复它。希望本文能帮到和我有同样困扰的朋友。

2025/10/15
articleCard.readMore

Java|FreeMarker 复用 layout

项目里的页面一多,重复的页面布局就不可避免地冒了出来,作为程序员,消除重复,义不容辞。那么,今天就来聊聊如何在 FreeMarker 中复用页面 layout,让代码更优雅、更易维护。 常规做法:include FreeMarker 提供了 include 指令,可以把一些公共页面元素单独提取出来,然后在需要的地方通过 include 引入,例如: <#-- includes/header.ftl --> <p>我来组成头部</p> <#-- includes/footer.ftl --> <p>我来组成底部</p> <#-- somepage.ftl --> <#include "./includes/header.ftl"> <p>我是页面内容</p> <#include "./includes/footer.ftl"> <script> // 这里是一些 JavaScript 代码 </script> 去除重复:抽象 layout 但是所有类似的页面都要手写这个结构也挺麻烦的,更糟糕的是,一旦这些页面的结构发生变化,得在 N 个页面里反复修改,想想都头大。 很多博客引擎(比如 Jekyll)都支持 layout 功能,允许我们定义统一的页面布局,具体页面只需专注于内容。 FreeMarker 虽然没有内置 layout,但我们可以用 macro 来实现类似的效果。 比如,抽象出一个 layout/page.ftl 文件,作为布局模板: <#-- layout/page.ftl --> <#macro layout body js=""> <#include "../includes/header.ftl" /> ${body} <#include "../includes/footer.ftl" /> ${js} </#macro> 然后在需要的页面这样用: <#import "./layout/page.ftl" as base> <#assign body> <p>我是页面内容</p> <p>当前时间:<span id="current-time">${.now?string("yyyy-MM-dd HH:mm:ss")}</span></p> </#assign> <#assign js> <script> // 每隔一秒刷新当前时间 setInterval(function() { document.getElementById("current-time").innerHTML = new Date().toLocaleString(); }, 1000); </script> </#assign> <@base.layout body=body js=js /> 页面效果如下: 减少手工输入:code snippets 虽然布局复用问题解决了,但每次新建页面还得手写一遍结构,还是不够优雅。程序员的信条是:能自动化的绝不手动! 这时就轮到编辑器/IDE 的 code snippets 功能登场了。把上面的结构定义成代码片段,新建页面时只需输入一个触发词,基本结构就自动生成。 以 VSCode 为例,可以在项目的 .vscode 目录下新建 layout.code-snippets 文件,内容如下: { "page_layout": { "scope": "ftl", "prefix": "layout:page", "body": [ "<#import \"./layout/page.ftl\" as base>", "", "<#assign body>", "", "", "", "</#assign>", "", "<#assign js>", "", "<script>", "", "</script>", "", "</#assign>", "", "<@base.layout body=body js=js />" ], "description": "Page layout template for FTL files" } } 这样新建 .ftl 文件后,输入 layout:page,页面布局结构就自动生成了。 如图所示: IntelliJ IDEA 也可以用 Live Templates 实现同样的效果。 本文相关代码和示例已上传至 GitHub,见 https://github.com/mzlogin/learn-spring 的 freemarker-test 目录。

2025/8/30
articleCard.readMore

DIY|Mac 搭建 ESP-IDF 开发环境及编译小智 AI

前一阵子在百度 AI 开发者大会上,看到基于小智 AI DIY 玩具的演示,感觉有点意思,想着自己也来试试。 如果只是想烧录现成的固件,乐鑫官方除了提供了 Windows 版本的 Flash 下载工具 之外,还提供了基于网页版的 ESP LAUNCHPAD,按照说明在 Mac 上也可以使用。 而我想着后期做一些定制,所以还是需要在 Mac 上搭建 ESP-IDF 开发环境,自己编译和烧录固件。而这个在 小智 AI 聊天机器人百科全书 中没有详细提及,所以我就记录一下搭建过程,供有需要的朋友参考。 先上一个跑起来后的效果: 配置 macOS 平台工具链 这一步参考乐鑫官方的 Linux 和 macOS 平台工具链的标准设置 完成,我这里指定了使用 ESP-IDF v5.4.1 版本,编译目标是 ESP32-S3。 第一步:安装前置依赖 brew install cmake ninja dfu-util ccache python3 第二步:获取 ESP-IDF mkdir ~/github cd ~/github git clone -b v5.4.1 --recursive https://github.com/espressif/esp-idf.git ESP-IDF 将下载至 ~/github/esp-idf 目录。 第三步:设置工具 cd ~/github/esp-idf ./install.sh esp32s3 第四步:设置环境变量 在 ~/.zshrc 中添加以下内容: alias get_idf='. $HOME/github/esp-idf/export.sh' 然后 source ~/.zshrc 使其生效。 这样在需要用到 ESP-IDF 环境的时候,只需要在终端中执行 get_idf 即可。 在执行以上步骤时,如果遇到问题,可以到 乐鑫官方文档 里看看有没有解决方案。 下载和编译小智 AI 固件 cd ~/github git clone -b v1.6.2 git@github.com:78/xiaozhi-esp32.git cd xiaozhi-esp32 然后接入 ESP32-S3 开发板,执行以下命令: get_idf idf.py set-target esp32s3 idf.py build idf.py flash monitor 一切顺利的话,会向 ESP32-S3 开发板烧录小智 AI 固件,并且进入监控模式。 至此,就初步能跑起来了。按照提示进行 WiFi 配置和小智 AI 平台的设备绑定,即可开始使用。 如果后续需要定制固件,可以基于 ~/github/xiaozhi-esp32 目录进行修改和编译。若习惯使用 VSCode 进行开发,可以安装 适用于 VSCode 的 ESP-IDF 扩展,这样可以更方便地进行开发和调试。 参考链接 https://www.espressif.com.cn/zh-hans/support/download/other-tools https://espressif.github.io/esp-launchpad/ https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb https://docs.espressif.com/projects/esp-idf/zh_CN/v5.4.1/esp32s3/get-started/linux-macos-setup.html https://github.com/espressif/vscode-esp-idf-extension

2025/6/5
articleCard.readMore

Mac mini 外接三方键盘如何微调音量

用 Mac mini 外接第三方键盘时,音量调节可能会让人感到痛苦。比如,Fn + F11 和 Fn + F12 这对音量调节快捷键可能没法用,而基于它们的微调组合键(Fn + Option + Shift + F11 和 Fn + Option + Shift + F12)更是想都别想。 我的工作键盘是 IKBC C87,虽然有个 Fn 键,但它和 Mac 键盘的 Fn 完全不是一回事。键盘自带的音量调节组合键只能一格一格地调节,听歌时还好,但在 Coding 时,这“一格”的音量有时就显得过于喧闹,让人无法沉浸式思考。微调音量成了刚需。 最终,Karabiner-Elements 拯救了我。 设置方法 只需将右 Ctrl 键映射为 Fn 键,就能像用苹果妙控键盘一样,正常使用各种基于 Fn 的快捷键,包括音量微调。 关于 Karabiner-Elements Karabiner-Elements 是一款强大的键盘映射工具,功能远不止映射单键这么简单。它还能: 创建自定义快捷键组合; 设计复杂的键盘规则; 根据不同应用程序或环境动态调整键盘映射。 此外,官网还提供了丰富的规则库,按需导入即可:https://ke-complex-modifications.pqrs.org/。 现在,我终于可以在 Coding 时享受“刚刚好”的背景音乐,而不用被“一格音量”的霸道支配。 希望能帮到和我一样有此困扰的你。

2025/5/8
articleCard.readMore

Java|小数据量场景的模糊搜索体验优化

在小数据量场景下,如何优化模糊搜索体验?本文分享一个简单实用的方案,虽然有点“土”,但效果还不错。 场景 假设有一张表 t_course,数据量在三到四位数,字段 name 需要支持模糊搜索。用普通的 LIKE 语句,比如: SELECT id, name FROM t_course WHERE name LIKE '%2025数学高一下%'; 结果却查不到 2025年高一数学下学期。这就很尴尬了,用户体验直接拉胯。 方案探索 1. MySQL 全文索引 首先想到 MySQL 的全文索引,但要支持中文分词得改 ngram_token_size 配置,还得重启数据库。为了不动生产环境配置,果断放弃。 2. Elasticsearch 接着想到 Elasticsearch,但对这么简单的场景来说,未免有点“杀鸡用牛刀”。于是继续寻找更轻量的方案。 3. 自定义分词 + MySQL INSTR 最后想到一个“土办法”:先对用户输入进行分词,再用 MySQL 的 INSTR 函数匹配。简单粗暴,但很实用。 实现 分词工具 一开始用了 jcseg 分词库,写了个工具类: public class JcSegUtils { private static final SegmenterConfig CONFIG = new SegmenterConfig(true); private static final ADictionary DIC = DictionaryFactory.createSingletonDictionary(CONFIG); public static List<String> segment(String text) throws IOException { ISegment seg = ISegment.NLP.factory.create(CONFIG, DIC); seg.reset(new StringReader(text)); IWord word; List<String> result = new ArrayList<>(); while ((word = seg.next()) != null) { String wordText = word.getValue(); if (StringUtils.isNotBlank(wordText)) { result.add(wordText); } } return result; } } 本地测试一切正常,但部署到测试环境后,分词结果却变了!比如: 本地:[2025, 数学, 高一, 下] 测试环境:[2025, 数, 学, 高, 1, 下] 原因是 jcseg 在 jar 包中加载默认配置和词库时出问题了。网上的解决方案大多是外置词库,但我懒得折腾,决定自己撸个简易分词工具。 简易分词工具 最终实现如下: public class WordSegmentationUtils { private static final List<String> DICT; private static final String COURSE_SEARCH_KEYWORD_LIST = "数学,物理,化学,生物,地理,历史,政治,英语,语文,高中,高一,高二,高三"; static { DICT = new ArrayList<>(); for (int i = 2018; i <= 2099; i++) { DICT.add(String.valueOf(i)); } DICT.addAll(Arrays.asList(COURSE_SEARCH_KEYWORD_LIST.split(","))); } public static List<String> segment(String text) { if (StringUtils.isBlank(text)) { return new ArrayList<>(); } List<String> segments = new ArrayList<>(); segments.add(text); for (String word : DICT) { segments = segment(segments, word); } return segments; } private static List<String> segment(List<String> segments, String word) { List<String> newSegments = new ArrayList<>(); for (String segment : segments) { if (segment.contains(word)) { newSegments.add(word); String[] split = segment.split(word); for (String s : split) { if (StringUtils.isNotBlank(s)) { newSegments.add(s.trim()); } } } else { newSegments.add(segment); } } return newSegments; } } 这个工具基于一个简单的词典 DICT,按词典中的词对输入文本进行分割。比如: 输入:2025数学高一下 输出:[2025, 数学, 高一, 下] 效果验证 现在,无论用户输入以下哪种形式,都能成功匹配到 2025年高一数学下学期: 2025高一数学下 2025 高一 数学 数学高一2025 小结 这个方案虽然简单,但在小数据量场景下,性能和体验都能满足需求,且实现成本低。如果遇到特殊情况,可以通过动态更新词典来解决。 当然,这种“土办法”并不适合复杂场景。如果需求升级,可以再考虑 MySQL 全文索引或 Elasticsearch。 最后,自己一个人负责开发和运维就是任性!如果有团队一起评审,这方案可能早就被否了吧……额,达摩克利斯之剑高悬。

2025/4/23
articleCard.readMore

家里老人迷信医疗广告?我可能找到办法了

有没有这样一种奇妙体验:家里老人对你的忠告嗤之以鼻,却对网络医疗广告深信不疑?仿佛那些”三天速效”“纯天然”“祖传秘方”字样自带某种魔力,能让他们心甘情愿掏空钱包? 我爸就是这样。医院检查出肠道息肉后,他不愿接受正规治疗,却在网上找了个不知名的乡镇医院,买了一堆贵得离谱的中药。当我劝他去本市三甲医院时,我们吵了一架,不欢而散。 我百思不得其解:为什么长辈会信任网上随机广告,胜过亲生儿女的劝告?是年代差异让他们天然信任媒体?是搜索引擎大品牌的背书效应?还是他们骨子里相信”酒香也怕巷子深,神医总在小诊所”? 曾经,我尝试给他安装丁香医生,希望提供专业的医疗参考。结果他嫌上面评论太少,不够可信。转头却给我展示抖音上的”体外无痛胃肠检查”广告——零评论,明显广告标识。我内心:这双标技术堪称奥运冠军水平啊! 绝望之际,我发现了转机。 一天晚上,我爸向我展示他用”豆包”AI制作的视频。灵光乍现!我让他用豆包查询那个”神奇”的体外检查技术。豆包给出了客观分析,而他竟然接受了这个意见! 第二天他主动用豆包查询其他健康问题,我立刻顺水推舟:”以后不用上百度了,直接问豆包就好。”他居然欣然接受! 所以,我所谓的方法其实很简单:用 AI 助手替代浏览器和搜索引擎。无论是豆包、DeepSeek 还是其他 AI 产品,它们提供简洁明了的单一答案,不会展示五花八门的广告链接,也(暂时)不会被商业利益左右。 感谢技术进步,我终于不用在”爸,这是骗人的”和”儿子,你懂什么”之间无限循环了。 类似的救赎,tk 教主的经历显然更加硬核,但我可能来不及去建立那样的信任了:

2025/3/21
articleCard.readMore

iOS|解决 setBrightness 调节屏幕亮度不生效的问题

在包含视频播放功能的 App 中,一种常见的交互是在播放器界面的左侧上下滑动调节屏幕亮度,右侧上下滑动调节音量。我们的 iOS App 里也是这样设计的,但最近在测试过程中,发现亮度调节不生效了。 摸索之路 代码里面调节亮度的实现是这样的: - (void)setBrightnessUp { if ([UIScreen mainScreen].brightness >=1) { return; } [UIScreen mainScreen].brightness += 0.01; // ... } - (void)setBrightnessDown { if ([UIScreen mainScreen].brightness <=0) { return; } [UIScreen mainScreen].brightness -= 0.01; // ... } 这个实现在较早之前是没有问题的,那我首先想到比较可能是因为系统的更新,对这个 API 做了变更。于是先查阅了 UIKit/UIScreen/brightness 的官方文档,里面只提到了 brightness 属性只在 main screen 上被支持,取值范围是 [0.0, 1.0],以及亮度调节后,直到锁屏后才会失效——即使用户在锁屏之前已经关闭了 App。并没有看到什么值得特别留意的。 然后继续看代码里的 UIScreen.mainScreen,这个属性被标记为: API_DEPRECATED("Use a UIScreen instance found through context instead: i.e, view.window.windowScene.screen", ios(2.0, API_TO_BE_DEPRECATED), visionos(1.0, API_TO_BE_DEPRECATED)) 但当前在我使用的 SDK 18.2 版本中,这个属性应仍可正常使用。 在 Google 和 StackOverflow 找了一圈,大家讨论亮度调节不生效主要集中以下方面: 后台调用不生效; 模拟器上调节不生效; viewDidLoad and viewWillAppear 中调用不生效; 如何优雅地在 App 退出后恢复原有亮度; 也没有找到什么能匹配我的场景的解决方案。 加了一些日志,在调节亮度前后分别打印了 brightness 的值,发现它在调用 setBrightness 方法后并没有发生变化,也没有报错和告警,看起来就像是这个方法根本没有被调用一样。 也做了一些其它尝试,比如把调整亮度的代码显式调度到主线程、使用 view.window.windowScene.screen 替代 UIScreen.mainScreen 等,但都没有效果。 无奈之下,我问了 GitHub Copilot 一嘴,它的回答是这样的: 我按它的建议检查了权限,确认了不存在权限问题。 有点绝望之际,看到它提供的代码里调整亮度的粒度是 0.1,而我的代码里是 0.01,于是我尝试将粒度改为 0.1,然后奇迹发生了,亮度调节生效了。 这就有点匪夷所思了……于是我又尝试了其它的粒度值,结果如下: 0.01,不生效; 0.02,不生效; 0.03 及以上,生效,但是从输出可以看到,实际调整后的亮度值都是 0.05 的倍数,即 0.05、0.1、0.15、0.2……,而不是 0.03、0.06、0.09、0.12…… 我找到安装了以前老版本 App 的一个老平板(iOS 10.3.3),在上面测试了一下,发现在这个版本上,0.01 的调节粒度是可以生效的。 也就是说,在 iOS (10.3.3, 18.2) 之间靠近后者的某个版本上,[UIScreen mainScreen].brightness 的调节粒度发生了变化,由 0.01 变为了 0.05。 至此破案了,顺便吐槽一下,官方文档里对此毫无提及,实在是……略坑。 参考 https://developer.apple.com/documentation/uikit/uiscreen/brightness?language=objc https://stackoverflow.com/questions/54229300/not-able-to-set-brightness-when-app-enter-in-background-can-any-one-have-any-id https://stackoverflow.com/questions/12362885/ios-uiscreen-setbrightness-doesnt-work https://stackoverflow.com/questions/61765014/why-cannot-i-set-uiscreen-main-brightness

2025/2/26
articleCard.readMore

iOS|记一名 iOS 开发新手的前两次 App 审核经历

说来惭愧,独立支撑公司的软件系统已经一年有余,多数的精力都在开发和迭代 Web 服务与 Android 端,对于 iOS App 则是一直没有更新,遇到相关的 bug 反馈也是能拖就拖——毕竟,大多数情况下找个 workaround 还是不难的。 回过头想想,可能潜意识里一直有点犯怵,觉得 iOS 开发是自己的薄弱环节,所以总想着等有时间,再多学一点相关的东西,准备得更充分、更有自信能处理好了,再去更新。可一直这样下去也不是办法,所以春节前结合一些业务需求,我决定逼自己一把,尽快把 iOS App 更新一下。 面对一个个所谓难题: Objective-C 的语法一阵没用又快忘光了——突击复习了一波; API 的细节不熟悉——看文档,参考老代码里的写法; 有一些变动不确定是否影响兼容性——查文档,问老同事,做测试; …… 然后在这个 AI 大行其道的时代,作为尊贵的 GitHub Copilot Pro 用户,在插件的辅助下光速添加了一个新的小功能,修复了一些 bug 后,我向 App Store Connect 提交了我的第一次版本审核,本以为需要经过漫长的等待,结果…… 事情出乎意料地顺利,几个小时就通过了,这玩意也有新手保护期? 满怀得意我心欢喜,于是一鼓作气把囤积已久的几个 feature 给做了,然后兴冲冲地提交了第二次版本审核,结果…… 几个小时后第一次被驳回,原因是: Guideline 3.1.1 - Business - Payments - In-App Purchase We found in our review that your app or its metadata provides access to mechanisms other than in-app purchase for purchases or subscriptions to be used in the app, which does not comply with the App Review Guidelines. Specifically: - Your app's binary includes the following call-to-action and/or URL that directs users to external mechanisms for purchases or subscriptions to be used in the app: User have to contact customer service to puchase credits. 看截图我查到了这是几年前参考一个大厂 App 实现的效果,在用户余额不足时,弹出一个提示框,上面有两个按钮,一个点击后展示了客服的联系方式,一个是「取消」,点击后跳转到充值页面。 我以为这里面的主要问题点是没有明确的「去充值」入口,导致审核人员以为用户无法直接充值,必须联系客服,于是我添加了一个「去充值」的按钮,将「取消」按钮的动作改为隐藏提示框,然后再次提交审核,结果几个小时后又被驳回了,原因仍然是: - Your app's "xxx" page includes the following call-to-action and/or URL that directs users to external mechanisms for purchases or subscriptions to be used in the app 不过发过来的消息里还有这样一段: Bug Fix Submissions The issues we've identified below are eligible to be resolved on your next update. If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed. Alternatively, if you'd like to resolve these issues now, please review the details, make the appropriate changes, and resubmit. 其实,这时候我只要回复个消息,说我这次提交包含了一些 bug 修复,希望能通过审核,下次我再修复这个问题,就妥了…… 但我当时脑子里不知道咋想的,可能是觉得这么个小问题,这次解决掉算了,然后就又改了一版,将那个跳转到客服联系方式的按钮去掉,又提交了一版,这时候我以为这把稳了,就收工等消息了。 回家正刷着沙雕视频呢,弹出来消息,又被拒了,这回是什么原因呢…… Guideline 2.1 - Performance - App Completeness Issue Description The app exhibited one or more bugs that would negatively impact App Store users. Bug description: unable to load "xxx" 我人都懵了,同时懊恼万分,真不应该装这个逼,就该回个消息,让审核员先通过再说。 话说回来,这个 xxx 功能已经在线上跑了几年了,最近也没改过,最后思来想去,怀疑可能是审核员当时遇上了什么网络波动之类的,导致没加载出来。 没辙,我只好在一些设备上复测了一下,保存了一些该功能正常使用的截图,然后回复给审核员,表明这个功能经我多次测试是正常的,已经上线运行了几年且最近没有修改过,希望审核员能再次确认,如果可以的话帮忙通过审核。 然后这回真的是漫长的等待,等了两天多,度过了一个忐忑的周末后,终于在周一一大早盼来了好消息。这一个版本的审核历时五天,经过了三轮被拒,总算是磕磕绊绊地通过了: 以上就是我这个 iOS 开发新手的前两次 App 审核经历,总结一下,主要有以下几点: 充分测试,保证功能的完整和稳定; 对 App 审核 的相关文档保持关注,避免一些容易被驳回的问题; 有问题及时回复审核员,解释清楚问题所在,提供相关的截图、视频等证据。 总的来讲,相比 Android App 需要提交到各家应用市场,然后面临不同的审核标准和结果,iOS App 的审核体验相对还是不错的,毕竟只用面对唯一的渠道和标准。

2025/2/25
articleCard.readMore

Mac mini 通过键盘连接蓝牙鼠标

前一阵发生过两次 Mac mini 与蓝牙鼠标断连的情况,都是通过借用别人的有线鼠标来重新连接的,终究不方便。 后来就想着,能不能通过键盘来连接蓝牙鼠标呢?摸索了一番,找到了方法,在此记录一下。 先上操作演示: 前置知识 看着上面的操作,是不是感觉 so easy?但实际上,我在操作过程中,一开始就遇到一个问题——焦点无法移动到设置面板右侧的按钮上。 这时我们要先了解一个关键的设置开关,以及它对应的快捷键: 该开关默认关闭,切换开关的默认快捷键是 Ctrl + F7。 通过键盘连接蓝牙鼠标的方法 通过键盘操作,打开系统设置: 按下 Cmd + 空格,调出 Spotlight 搜索框; 输入 系统设置,回车。 通过键盘上下键,定位到 蓝牙; 操作蓝牙鼠标,使其进入配对模式;(一般是长按鼠标底部的配对键) 连续按下 Tab 键,定位到蓝牙鼠标对应设备的 连接 按钮; 注意: 这里有可能发现,按 Tab 键焦点无法移动到设置面板右侧,这时就需用到我们前面提到的设置开关了,按下 Ctrl + F7,开启键盘导航功能,再按 Tab 键就可以移动焦点了。 按下 Space 键,连接蓝牙鼠标。 大功告成! 其它思路 除上了述方法,我搜索的过程中还看到有一些其它思路,比如: Cmd + Option + F5 打开辅助功能里的一些开关,然后通过键盘模拟鼠标操作; 喊 Siri 帮你重启蓝牙; 可以按需尝试和使用。

2025/1/9
articleCard.readMore

Java|如何用一个统一结构接收成员名称不固定的数据

本文介绍了一种 Java 中如何用一个统一结构接收成员名称不固定的数据的方法。 背景 最近在做企业微信的内部应用开发,遇到了一个小问题:企业微信的不同接口,返回的数据的结构不完全一样。 比如,获取部门列表接口返回的数据结构是这样的: { "errcode": 0, "errmsg": "ok", "department": [ { "id": 2, "name": "广州研发中心", "name_en": "RDGZ", "department_leader":["zhangsan","lisi"], "parentid": 1, "order": 10 } ] } 而获取部门成员接口返回的数据结构是这样的: { "errcode": 0, "errmsg": "ok", "userlist": [ { "userid": "zhangsan", "name": "张三", "department": [1, 2], "open_userid": "xxxxxx" } ] } 就是说,不同接口的返回框架是一样的,都是 errcode + errmsg + 数据部分,但数据部分的成员名称不一样,比如上面的 department 和 userlist。 我不知道为什么这样设计,从 Java 开发者的习惯来讲,如果由我来设计,我会尽量保持接口返回的数据结构的一致性,比如数据部分都用 data 来表示,这样在序列化、反序列化的时候可以用一个统一的泛型结构来进行。 当然这可能是企微内部的开发语言或习惯的差异,或者其它原因,这里也无法深究,只谈如何应对。 分析 遇到这个问题后,第一反应是用 JSON 结构来接收,然后不同接口的数据部分用不同的 key 来读取。可以实现,但总觉得不够优雅。 然后想到 GitHub 上应该有不少开源的企微开发的封装库,去看看它们的实现,说不定会有更好的方案,最终果然有收获。 主要看了两个库: https://github.com/binarywang/WxJava https://github.com/NotFound403/wecom-sdk 前者 WxJava 知名度更高,包含的东西也更多,包含微信、企微的各种开发包的封装。它这块的实现是用我们前面提到的方法,用 JSON 结构来接收,然后不同接口的数据用不同的 key 来读取。 后者 wecom-sdk 是企微的开发包。它这块的实现是用了一个统一的泛型结构来接收数据。 以下分别截取两个库的两个部门管理相关接口的封装代码: WxJava 版: https://github.com/binarywang/WxJava/blob/develop/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpDepartmentServiceImpl.java @Override public List<WxCpDepart> list(Long id) throws WxErrorException { String url = this.mainService.getWxCpConfigStorage().getApiUrl(DEPARTMENT_LIST); if (id != null) { url += "?id=" + id; } String responseContent = this.mainService.get(url, null); JsonObject tmpJsonObject = GsonParser.parse(responseContent); return WxCpGsonBuilder.create() .fromJson(tmpJsonObject.get("department"), new TypeToken<List<WxCpDepart>>() { }.getType() ); } @Override public List<WxCpDepart> simpleList(Long id) throws WxErrorException { String url = this.mainService.getWxCpConfigStorage().getApiUrl(DEPARTMENT_SIMPLE_LIST); if (id != null) { url += "?id=" + id; } String responseContent = this.mainService.get(url, null); JsonObject tmpJsonObject = GsonParser.parse(responseContent); return WxCpGsonBuilder.create() .fromJson(tmpJsonObject.get("department_id"), new TypeToken<List<WxCpDepart>>() { }.getType() ); } } wecom-sdk 版: https://github.com/NotFound403/wecom-sdk/blob/release/wecom-sdk/src/main/java/cn/felord/api/DepartmentApi.java @GET("department/list") GenericResponse<List<DeptInfo>> deptList(@Query("id") long departmentId) throws WeComException; @GET("department/simplelist") GenericResponse<List<DeptSimpleInfo>> getSimpleList(@Query("id") long departmentId) throws WeComException; 抛开 wecom-sdk 版引入了 Retrofit2 库的支持导致的代码量锐减,在返回数据的反序列化上,我也更倾向于 wecom-sdk 版的实现。 实现 那接下来我们直接参照 wecom-sdk 里的实现方式,写一个泛型类,就可以用来接收企微的不同接口返回的数据了: @Data public class WxWorkResponse<T> { @JsonProperty("errmsg") private String errMsg; @JsonProperty("errcode") private Integer errCode; @JsonAlias({ "department", "userlist" }) private T data; } 这里面起到关键作用的是 Jackson 库里的 @JsonAlias 注解。它的官方文档是这样介绍的: Annotation that can be used to define one or more alternative names for a property, accepted during deserialization as alternative to the official name. Alias information is also exposed during POJO introspection, but has no effect during serialization where primary name is always used. Examples: public class Info { @JsonAlias({ "n", "Name" }) public String name; } NOTE: Order of alias declaration has no effect. All properties are assigned in the order they come from incoming JSON document. If same property is assigned more than once with different value, later will remain. For example, deserializing public class Person { @JsonAlias({ "name", "fullName" }) public String name; } from { "fullName": "Faster Jackson", "name": "Jackson" } will have value "Jackson". Also, can be used with enums where incoming JSON properties may not match the defined enum values. For instance, if you have an enum called Size with values SMALL, MEDIUM, and LARGE, you can use this annotation to define alternate values for each enum value. This way, the deserialization process can map the incoming JSON values to the correct enum values. Sample implementation: public enum Size { @JsonAlias({ "small", "s", "S" }) SMALL, @JsonAlias({ "medium", "m", "M" }) MEDIUM, @JsonAlias({ "large", "l", "L" }) LARGE } During deserialization, any of these JSON structures will be valid and correctly mapped to the MEDIUM enum value: {"size": "m"}, {"size": "medium"}, or {"size": "M"}. 回到我们的例子,除了 department 和 userlist 之外还用到其它的 key,可以继续在 @JsonAlias 注解里添加。 这样,对不同的接口的封装,我们反序列化后统一 getData() 就可以获取到数据部分了,使用时不用再去操心数据部分的 key 是什么。 小结 有人总问,阅读别人源码的意义是什么,这也许就可以作为一个小例子吧。

2024/11/29
articleCard.readMore