2025-08-30 00:00:00
项目里的页面一多,重复的页面布局就不可避免地冒了出来,作为程序员,消除重复,义不容辞。那么,今天就来聊聊如何在 FreeMarker 中复用页面 layout,让代码更优雅、更易维护。
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>
但是所有类似的页面都要手写这个结构也挺麻烦的,更糟糕的是,一旦这些页面的结构发生变化,得在 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 />
页面效果如下:
虽然布局复用问题解决了,但每次新建页面还得手写一遍结构,还是不够优雅。程序员的信条是:能自动化的绝不手动!
这时就轮到编辑器/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-06-05 00:00:00
前一阵子在百度 AI 开发者大会上,看到基于小智 AI DIY 玩具的演示,感觉有点意思,想着自己也来试试。
如果只是想烧录现成的固件,乐鑫官方除了提供了 Windows 版本的 Flash 下载工具 之外,还提供了基于网页版的 ESP LAUNCHPAD,按照说明在 Mac 上也可以使用。
而我想着后期做一些定制,所以还是需要在 Mac 上搭建 ESP-IDF 开发环境,自己编译和烧录固件。而这个在 小智 AI 聊天机器人百科全书 中没有详细提及,所以我就记录一下搭建过程,供有需要的朋友参考。
先上一个跑起来后的效果:
这一步参考乐鑫官方的 Linux 和 macOS 平台工具链的标准设置 完成,我这里指定了使用 ESP-IDF v5.4.1 版本,编译目标是 ESP32-S3。
brew install cmake ninja dfu-util ccache python3
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
即可。
在执行以上步骤时,如果遇到问题,可以到 乐鑫官方文档 里看看有没有解决方案。
cd ~/github
git clone -b v1.6.2 [email protected]: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 扩展,这样可以更方便地进行开发和调试。
2025-05-08 00:00:00
用 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 是一款强大的键盘映射工具,功能远不止映射单键这么简单。它还能:
此外,官网还提供了丰富的规则库,按需导入即可:https://ke-complex-modifications.pqrs.org/。
现在,我终于可以在 Coding 时享受“刚刚好”的背景音乐,而不用被“一格音量”的霸道支配。
希望能帮到和我一样有此困扰的你。
2025-04-23 00:00:00
在小数据量场景下,如何优化模糊搜索体验?本文分享一个简单实用的方案,虽然有点“土”,但效果还不错。
假设有一张表 t_course
,数据量在三到四位数,字段 name
需要支持模糊搜索。用普通的 LIKE
语句,比如:
SELECT id, name FROM t_course WHERE name LIKE '%2025数学高一下%';
结果却查不到 2025年高一数学下学期
。这就很尴尬了,用户体验直接拉胯。
首先想到 MySQL 的全文索引,但要支持中文分词得改 ngram_token_size
配置,还得重启数据库。为了不动生产环境配置,果断放弃。
接着想到 Elasticsearch,但对这么简单的场景来说,未免有点“杀鸡用牛刀”。于是继续寻找更轻量的方案。
最后想到一个“土办法”:先对用户输入进行分词,再用 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-03-21 00:00:00
有没有这样一种奇妙体验:家里老人对你的忠告嗤之以鼻,却对网络医疗广告深信不疑?仿佛那些”三天速效”“纯天然”“祖传秘方”字样自带某种魔力,能让他们心甘情愿掏空钱包?
我爸就是这样。医院检查出肠道息肉后,他不愿接受正规治疗,却在网上找了个不知名的乡镇医院,买了一堆贵得离谱的中药。当我劝他去本市三甲医院时,我们吵了一架,不欢而散。
我百思不得其解:为什么长辈会信任网上随机广告,胜过亲生儿女的劝告?是年代差异让他们天然信任媒体?是搜索引擎大品牌的背书效应?还是他们骨子里相信”酒香也怕巷子深,神医总在小诊所”?
曾经,我尝试给他安装丁香医生,希望提供专业的医疗参考。结果他嫌上面评论太少,不够可信。转头却给我展示抖音上的”体外无痛胃肠检查”广告——零评论,明显广告标识。我内心:这双标技术堪称奥运冠军水平啊!
绝望之际,我发现了转机。
一天晚上,我爸向我展示他用”豆包”AI制作的视频。灵光乍现!我让他用豆包查询那个”神奇”的体外检查技术。豆包给出了客观分析,而他竟然接受了这个意见!
第二天他主动用豆包查询其他健康问题,我立刻顺水推舟:”以后不用上百度了,直接问豆包就好。”他居然欣然接受!
所以,我所谓的方法其实很简单:用 AI 助手替代浏览器和搜索引擎。无论是豆包、DeepSeek 还是其他 AI 产品,它们提供简洁明了的单一答案,不会展示五花八门的广告链接,也(暂时)不会被商业利益左右。
感谢技术进步,我终于不用在”爸,这是骗人的”和”儿子,你懂什么”之间无限循环了。
类似的救赎,tk 教主的经历显然更加硬核,但我可能来不及去建立那样的信任了:
2025-02-26 00:00:00
在包含视频播放功能的 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 找了一圈,大家讨论亮度调节不生效主要集中以下方面:
也没有找到什么能匹配我的场景的解决方案。
加了一些日志,在调节亮度前后分别打印了 brightness 的值,发现它在调用 setBrightness 方法后并没有发生变化,也没有报错和告警,看起来就像是这个方法根本没有被调用一样。
也做了一些其它尝试,比如把调整亮度的代码显式调度到主线程、使用 view.window.windowScene.screen
替代 UIScreen.mainScreen
等,但都没有效果。
无奈之下,我问了 GitHub Copilot 一嘴,它的回答是这样的:
我按它的建议检查了权限,确认了不存在权限问题。
有点绝望之际,看到它提供的代码里调整亮度的粒度是 0.1,而我的代码里是 0.01,于是我尝试将粒度改为 0.1,然后奇迹发生了,亮度调节生效了。
这就有点匪夷所思了……于是我又尝试了其它的粒度值,结果如下:
我找到安装了以前老版本 App 的一个老平板(iOS 10.3.3),在上面测试了一下,发现在这个版本上,0.01 的调节粒度是可以生效的。
也就是说,在 iOS (10.3.3, 18.2) 之间靠近后者的某个版本上,[UIScreen mainScreen].brightness 的调节粒度发生了变化,由 0.01 变为了 0.05。
至此破案了,顺便吐槽一下,官方文档里对此毫无提及,实在是……略坑。