Logo

site iconrxliuli | 琉璃

Web 前端开发,在日本,喜欢自称「吾辈」让我很难受。。博客主要记录一些技术相关的东西
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

rxliuli | 琉璃 RSS 预览

Safari 扩展 Popup 中的滚动迟缓问题

2026-08-07 11:43:38

前言

昨天在尝试为一个扩展程序增加 Safari 支持的时候,发现了一个神奇的问题,在 popup 中滚动感觉很迟缓。

就像是这样,滚动速度不仅缓慢,而且没有任何惯性效应。

如果在新标签页打开 popup 页面,你可以看到滚动要流畅的多

猜测

具体来说,复现步骤很简单

  1. 使用 JavaScript 写入大量内容产生滚动条,上面的示例中使用了一个相当简单的 for 循环 + ul list
  2. 打开 popup 弹窗,滚动就可以触发这个问题
1
2
3
4
5
6
7
8
// popup.js
const ul = document.createElement('ul')
document.body.appendChild(ul)
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = 'list ' + i
ul.appendChild(li)
}

在此之前,我其实已经遇到了几个棘手的问题,包括

  1. popup 在切换页面时会出现白屏,或者一半黑一般白 [1]
  2. prefers-color-scheme 有时探测不到,导致系统暗色模式下 popup 仍然是白色背景 [2]
  3. 路由切换后滚动位置不归零
  4. 滚动没有弹簧效果

幸运的是,4 个表面上毫不相干的错误实际上指向同一个问题:popup 原生窗口会根据内容高度自动 resize,而在 chrome/firefox 中并没有什么问题,但 safari 似乎仍然有一些边缘的错误。

解决

在尝试解决的过程中,一开始并未意识到是 resize 导致的问题,我尝试了不少方法,包括

  1. 去掉嵌套滚动容器,改用文档级滚动:最初使用了 h-screen + flex-column,这是为了固定顶栏不跟随页面滚动而移出页面之外,所以在最初碰到切换页面再回来首页就白屏的问题时,以为是首页哪里的实现有问题
  2. 内联关键 CSS 抢跑加载:在这个过程中又发现页面的暗色模式支持出了 bug,最诡异的是,popup 里面是错误的,但单独打开 popup 在新标签页又是正确的,非常诡异
  3. TanStack Router 内置的 scrollRestoration: true:所以在之后注意到切换页面滚动距离和白屏的高度有关系之后,我认为是页面切换之后没有自动滚动到顶部,所以尝试了这个配置
  4. 固定 body 的宽高并在内部使用滚动:最后灵机一动想到了是否有可能是内容引起的 resize 导致的频繁渲染,进而导致的滚动缓慢问题,没想到一下子 4 个问题全被解决了

关键代码如下

1
2
3
4
5
6
7
8
9
/* popup.js */
/* Safari only */
html.safari-fixed-popup,
html.safari-fixed-popup body {
width: 672px;
height: 600px;
overflow-x: hidden;
overflow-y: auto;
}
1
2
3
4
// popup.js
if (import.meta.env.SAFARI) {
document.documentElement.classList.add('safari-fixed-popup')
}

所以 safari popup 到底发生了什么呢?我查询来自官方文档中的信息只有一个,来自 WebKit Blog - Safari 26.2 更新日志 中的这样一句描述:extension popups could open scrolled down and some websites could flicker during scrolling

另外还找到两条相关但不完全对得上的线索:WebKit Bug 296056 报告了 iPadOS 26 上 popup 打开时滚动位置异常,已经修复,不过根因是 <dialog> 关闭后焦点恢复触发的滚动,跟这里的自动 resize 不是一回事;Apple Developer Forums 上的一个帖子 则直接印证了 resize-to-content 这套机制本身就不太可靠——有人报告 iPad 上 popup 容器撑高后再也缩不回去了。加起来看,这块至少不是我一个人遇到的边缘情况。

没有找到任何文档或 issue 把这 4 个现象和”根据内容自动 resize”这个根因关联起来,所以我把这次调试过程整理成了一份报告提交给了 Apple(FB24198765),同时也镜像发布到了专门收集 Safari 扩展 Feedback Assistant 报告的 lapcat/SafariExtensions 仓库 —— Feedback Assistant 本身是私有不可搜索的,这个仓库算是社区自发攒起来的公开备份。如果你也遇到过类似的问题,欢迎去那边 +1 或者补充你自己的复现场景。

结语

Safari 扩展在所有浏览器中都是一个异类,它强制扩展作为一个 app 封装和分发,也就是说,安装一个 Safari 扩展和安装一个 app 使用的安装包是一模一样的。即使不算扩展,Safari 浏览器对 Web 标准的支持也像是新时代的 IE,它如此糟糕,以至于 iOS 支持经常被视为一项痛苦的工作 – iOS 上所有浏览器都使用 webkit 内核,包括 Chrome 浏览器,Apple 禁止了浏览器使用自己的内核。

  1. Snipaste\_2026-08-06\_18-05-28.jpg
  2. 1786078055595.jpg,同样的现象在 Apple Developer Forums 上一个 2021 年的老帖子 里也有人报告过,至今没有回复。

Safari 云签名在 GitHub Actions 中的两个陷阱

2026-08-05 21:14:30

背景

使用 GitHub Actions 全自动发布 Safari 扩展 里,我写过一套在 macOS runner 上用 Xcode 云签名(-allowProvisioningUpdates)自动构建、签名、上传 Safari 扩展的流程。流程本身是能跑起来的,但在跑通之前,我踩过两个坑,而且巧的是,这两个坑都有同一个特点:报错信息含糊不清,搜索引擎上几乎搜不到答案。这篇文章把它们记录下来,包括现象、原因和修复方法。

两个坑追根溯源是同一件事:无状态的 CI runner,打破了苹果签名工具原本假设的前提

陷阱一:「maximum number of certificates」

场景是这样的:GitHub 提供的 macos-* runner,开着 -allowProvisioningUpdates 让云签名自动处理证书。一开始一切正常,版本一个接一个发出去。然后某一天,你什么都没改,构建却突然报错,说证书数量达到了上限。

真正发生的事情是这样的:GitHub 的每个 runner 启动时都是一个全新的、空的 keychain。云签名找不到可用的签名身份时,不会直接失败,而是很「贴心」地帮你的账号新建一张 Apple Distribution 证书,把私钥放进这次 runner 的 keychain 里。job 结束后 runner 被销毁,私钥跟着一起消失。证书本身还留在你的 Apple Developer 账号里,永久躺在那儿,再也没有对应的私钥能用它签名。

也就是说,每次发布都在悄悄烧掉一张证书。苹果对每个账号的 Distribution 证书数量设了上限,一旦顶到这个上限,云签名就没法再新建证书,构建从此开始失败。最阴险的地方在于:撞上限之前,每一次构建都是成功的,你完全看不出哪里不对,直到它毫无征兆地、永久性地坏掉。

Snipaste\_2026-07-28\_08-34-22.png

这是攒了一段时间之后的样子——十几张证书,全部叫「Created via API」,全部挤在大约一周之内。它们无一例外都是孤儿证书:创建它们的 runner 早就没了,私钥自然也不存在了。

解决办法就是不要再让 CI 自己新建证书:

  1. 自己手动创建一张 Apple Distribution 证书(Xcode → Settings → Accounts → Manage Certificates,或者去 开发者后台
  2. 导出为带密码的 .p12
  3. 把证书和密码都存进仓库的 secrets(.p12 需要先 base64)
  4. 在 CI job 一开始、xcodebuild 跑之前,把它导入 keychain
1
2
3
4
5
6
7
8
9
10
11
12
- name: Import signing certificate
run: |
echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
security create-keychain -p actions build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p actions build.keychain
security import certificate.p12 -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k actions build.keychain
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}

keychain 里已经有一个可用的签名身份之后,云签名每次都会复用它,而不是每次都新建一个。

注:修好这个问题之后,记得去开发者后台的证书列表,把 CI 之前留下的那一堆孤儿证书清理掉——找不到对应私钥的那些就是。

陷阱二:「Cloud signing permission error」

传给 xcodebuild 的 App Store Connect API Key 是有角色权限的,创建的时候就定死了。坑就在这里:一个 DeveloperApp Manager 角色的 key,身份认证是能通过的,然后构建会在签名进行到一半时失败,只甩给你一句语焉不详的「Cloud signing permission error」。

正是这种失败方式让人摸不着头脑:key 本身没有被拒绝,上传之类的其他 App Store Connect API 操作用这个权限也都正常,唯独云签名这一步会死,报错里连「权限」两个字都没提。

1785936111555.jpg

按我的实测,云签名要求 key 必须是 Admin 角色。角色在创建时就固定了、后续改不了,唯一的办法是重新生成一个:App Store Connect → Users and Access → Integrations → App Store Connect API → 生成一个 Admin 角色的 key,下载 .p8(只有这一次下载机会)。

注:Admin 角色的 .p8 要当成整套流程里真正的机密来对待,它是这里唯一的真机密。issuer id、key id、team id 这些都不是机密,.p8.p12 才是。

总结

两个坑追根溯源都是同一件事:苹果的签名工具链假设你在用一台长期存在的开发机,而无状态的 runner 打破了这个假设,失败得又晚又含糊,而不是又早又清楚。

写上一篇文章之后,我把这整套流程——生成 Xcode 工程、用上面这两个修复方式签名、上传、提交审核——都收进了 extport,也就是我现在给所有扩展发版用的工具。如果你不想自己维护这堆 YAML,它就是这篇和上一篇里讲的这一整套东西的打包版本。

使用 GitHub Actions 全自动发布 Safari 扩展

2026-07-20 15:08:07

背景

我之前写过两篇关于 Safari 扩展的博客:转换 Chrome Extension 为 Safari 版本,以及 发布 Safari 扩展到 iOS 应用商店。它们解决的是”第一次怎么做成”。但随着我的 Safari 扩展越来越多,每次发版的手动流程变得越来越难以忍受:打开 Xcode、改版本号、Archive、Validate、Distribute,再打开 App Store Connect 选 bundle、填 release note、点提交——每个扩展十分钟起步,而且不能出错。

这篇文章介绍我现在的发布流程:改一下 package.json 里的 version,push 到 GitHub,然后就没有然后了。构建、签名、上传、提交审核,全部在 GitHub Actions 里自动完成。

1784531579982.jpg

最终效果

先看终态。这是一个真实项目里 Safari 发布相关的完整 workflow 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
safari:
needs: version
runs-on: macos-26
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: 'pnpm'
- run: pnpm install
# 构建扩展并转换为 Xcode 项目,输出到 .output/My Extension
- run: pnpm build:safari

- uses: rxliuli/safari-webext-publish-action@v2
with:
# build 模式:构建、签名、上传 bundle 到 App Store Connect,但不提交审核
mode: build
project-path: '.output/My Extension'
bundle-identifier: 'com.example.my-extension'
team-id: 'ABCDE12345'
app-signing-identity: '3rd Party Mac Developer Application: Your Name (ABCDE12345)'
installer-signing-identity: '3rd Party Mac Developer Installer: Your Name (ABCDE12345)'
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_MACOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_PROVISIONING_PROFILE_BASE64 }}
APPLE_MACOS_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
APPLE_IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_IOS_PROVISIONING_PROFILE_BASE64 }}
APPLE_IOS_EXTENSION_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_IOS_EXTENSION_PROVISIONING_PROFILE_BASE64 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

safari-submit:
needs: [version, safari]
runs-on: ubuntu-latest
steps:
- uses: rxliuli/safari-webext-publish-action@v2
with:
# submit 模式:用刚上传的 bundle 创建新版本并提交审核,只需要 3 个 secrets
mode: submit
bundle-identifier: 'com.example.my-extension'
version: ${{ needs.version.outputs.version }}
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

配好这份 YAML 和它引用的 secrets,就是全部了。它用到了我为此写的两个工具:

为什么 Safari 需要专门的工具?Chrome/Firefox/Edge 的发布 WXT 已经做得很好了,一条命令的事。但 Safari 扩展本质上是一个 App——浏览器扩展的代码被嵌在一个 macOS/iOS 应用里,发布走的是 App Store,这意味着要面对 Xcode 项目、证书、签名、公证、provisioning profile 这一整套 Apple 的分发体系。复杂度的直观展示——这是一个全平台扩展需要的所有 secrets:

1784533468309.jpg

看起来吓人,但每一个都只需要配置一次。下面按顺序讲清楚每一步在做什么、每个 secret 从哪里来。

顺带一提:这套流程也是逐步演化出来的。最早我只用 wxt-module-safari-xcode 生成项目,仍然在 Xcode 里手动构建上传;后来 action 的 build 模式让我不再需要打开 Xcode;最后加上 submit 模式,才把最后”打开 App Store Connect 点两三分钟”的步骤也消灭掉。如果你想了解完整的手动流程(以及为什么它值得被自动化),见开头那两篇博客。

第一步:配置 WXT,把扩展转换为 Xcode 项目

Apple 官方提供了转换工具 xcrun safari-web-extension-converter,但它只做最基础的转换:生成的 Xcode 项目还需要手动设置版本号、App 分类、开发团队等。wxt-module-safari-xcode 把这些集成进了 WXT 的构建流程——运行 wxt build -b safari 时自动完成转换和全部配置注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
// wxt.config.ts
import { defineConfig } from 'wxt'

export default defineConfig({
modules: ['wxt-module-safari-xcode'],
safariXcode: {
projectName: 'Your Project Name',
appCategory: 'public.app-category.productivity',
bundleIdentifier: 'com.example.your-extension',
developmentTeam: 'ABC1234567',
},
// ... other configurations
})

四个配置项的来源:

  • projectName:扩展的显示名,包含大写字母或空格都没问题。
  • appCategory:App Store 的应用分类,可选值见 Apple 官方分类列表
  • bundleIdentifier:全局唯一标识,惯例是反转域名 + 应用名,全小写、不含空格。
  • developmentTeam:你的 Apple Developer Team ID,在 developer.apple.com/account 的 Membership 页面可以找到。

1784536060862.jpg

运行 pnpm wxt build -b safari 后,Xcode 项目会出现在 .output/<projectName>,版本号已经从 package.json 自动同步(模块会同时更新 MARKETING_VERSIONCFBundleVersion,后者按 major×10000 + minor×100 + patch 换算成递增的数字版本——Apple 要求每次上传的 build number 必须递增,这个换算规则让它跟着语义化版本自动满足)。

真实项目的配置示例:https://github.com/rxliuli/imp-translate/blob/main/wxt.config.ts

第二步:准备 secrets

这是整个流程中最繁琐的部分,但每项只做一次。分三组。

证书(APPLE_CERTIFICATE_BASE64 / APPLE_CERTIFICATE_PASSWORD)

CI 里签名的原理是:把你的分发证书(含私钥)导出成 .p12 文件,以 base64 形式存进 GitHub secrets,action 在运行时把它导入 runner 上的临时 keychain 供 Xcode 签名使用。步骤:

  1. developer.apple.com 的 Certificates 页面 创建三张分发证书:Mac App Distribution(对应 “3rd Party Mac Developer Application” 签名身份)、Mac Installer Distribution(对应 “3rd Party Mac Developer Installer”。看到”Installer”别疑惑——Mac App Store 规定的上传格式就是签过名的 .pkg,它只是提交给 Apple 时的运输容器,用户实际安装走的是 App Store 自己的机制,永远不会接触这个 .pkg。action 内部用 productbuild 打包时需要这张证书签名),以及 Apple Distribution——注意一个命名陷阱:创建页面上它叫 “Apple Distribution”,但创建后在证书列表里只显示为 “Distribution“(Platform 列为 “All”),是同一张证书。它是跨平台的,负责 iOS 分发签名。创建过程中需要用本机钥匙串生成 CSR(证书签名请求),按页面提示操作即可。这三张证书都是账户级的,不绑定任何具体扩展——配置一次,所有项目通用。
  2. 下载证书后双击导入本机钥匙串,然后打开「钥匙串访问」→「我的证书」,同时选中这三张证书(确认每张都能展开看到私钥),右键导出为单个 .p12 文件——action 支持一个 .p12 内包含全部签名身份,不需要分开传。导出时设置的密码就是 APPLE_CERTIFICATE_PASSWORD
  3. 把 .p12 转成 base64 存入 secrets:base64 -i Certificates.p12 | pbcopy,粘贴为 APPLE_CERTIFICATE_BASE64

1784542838372.jpg

Provisioning Profiles(4 个 *_PROVISIONING_PROFILE_BASE64)

Profiles 页面 为每个 target 创建 App Store 类型的 profile。转换器生成的项目里有 App 和 Extension 两个 target,各自有独立的 bundle id——Extension 的 id 是主 id 加 .Extension 后缀(例如 com.example.my-extension.Extension),两个 bundle id 都需要先在 Identifiers 页面注册。macOS 和 iOS 各一套,所以一共 4 个 profile。每个下载后同样 base64 编码存入对应的 secret。

1784542904113.jpg

注意:这一步是按扩展算的——每新增一个 Safari 扩展,都要为它创建一套新的 profile。这也是所有 secrets 里唯一不能跨项目复用的部分(证书和 API key 都是账户级的,配一次全部扩展通用)。

App Store Connect API(APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER)

App Store Connect 的 Integrations 页面 创建团队 API 密钥,角色选 App Manager 就足够(最小权限原则,不要用 Admin)。创建后:

  • 下载 .p8 私钥文件——只能下载这一次,妥善保存。用 base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy 转成 base64 后存入 APPLE_API_KEY
  • 页面上的 Key ID 存入 APPLE_API_KEY_ID
  • 页面顶部的 Issuer ID 存入 APPLE_API_ISSUER

这组密钥承担两个职责:build 模式用它上传 bundle(替代了 Xcode 的 Distribute 步骤),submit 模式用它调用 App Store Connect API 创建版本并提交审核。

1784542967542.jpg

找不到这些页面入口是正常的,Apple 的后台并不以易用著称。一个现代解法:让能操作浏览器的 LLM 帮你导航到对应页面,比自己翻文档快得多。

第三步:理解 workflow 的两个 job

回头看开头那份 YAML,Safari 发布被拆成了两个 job,这个拆分有讲究:

safari(build 模式) 跑在 macOS runner 上——只有这一步真正需要 macOS 和 Xcode(注意必须是 macos-26 及以上,action 会检查 Xcode 26 SDK)。它做四件事:导入证书到临时 keychain、用 xcodebuild 构建并签名(macOS 产出 x86_64+arm64 通用二进制)、打包、通过 API 上传到 App Store Connect。跑完之后,bundle 已经出现在 App Store Connect 的构建版本列表里,等价于你在 Xcode 里点完了 Archive → Distribute。一个省心的细节:action 会自动从 Xcode 项目的 scheme 检测目标平台——项目里只有 macOS scheme 就自动跳过 iOS 步骤,反之亦然,不需要额外配置。

safari-submit(submit 模式) 跑在 ubuntu runner 上——它只调用 App Store Connect API,不需要 macOS,用便宜的 runner 就好。它等价于你在网页上手动做的那套:轮询等待 bundle 处理完成、创建新版本、关联刚上传的 bundle、填写 What’s New、提交审核。也因此它只需要 API 三件套这 3 个 secrets。开头抱怨过的”每次都要手动填 release note”也在这里被解决了:通过 release-notes 输入传入,不传则默认 “Bug fixes and improvements.”。

1784543097189.jpg

总结

诚实地说:如果你只有一两个 Safari 扩展,Xcode 的手动流程是可以忍受的,它替你隐藏了证书和签名的大部分复杂性,为它配置这一整套 secrets 未必划算。但如果你有三个以上的 Safari 扩展,或者单个扩展发版频繁,这条流水线会彻底改变你的发版体验——一次性的配置成本,换来之后每次发版为零的心智负担。我的十几个 Safari 扩展现在全部走这条流水线,配置完成之后,我再也没有为发版打开过 Xcode。

旅行 - 东福山岛

2026-05-29 20:31:47

前言

自去年年底去过一次之后,我就喜欢上了在海岛上旅行的感觉,相比于大陆近岸的海水,海岛周围的颜色透着一种天空的蓝。

cover

环岛徒步

这次旅行又走了一圈环岛徒步,只花了不到 3 个小时,比上次要快得多。

碧蓝之海,启动!
东福山岛-环岛-1

远方还能看到几艘渔船。
东福山岛-环岛-2

靠近岸边的海水反而泛着一些浅绿色。
东福山岛-环岛-3

顺时针走到大约 1/4 的地方,可以看到一个废弃的村庄,似乎目前只然有一些海防人员在此居住。
东福山岛-环岛-4

回头望去,几座岛屿在海平线的地方,却是无法上去的无人岛。
东福山岛-环岛-5

无论何时何地,总会不经意间看到大橘的身影。
东福山岛-环岛-6

一直都很喜欢这种海水不断拍打着礁石的画面。
东福山岛-环岛-7
东福山岛-环岛-8

正午,整个海平面都泛着白光。
东福山岛-环岛-9

远方矗立着一个不知名的三角形箭头,不知道是做什么的。
东福山岛-环岛-10

似乎还有人居住的村庄,但也主要集中在东北角接近轮渡码头的地方。
东福山岛-环岛-11

日出日落

当下午回来之后,就可以等待日落了,轮渡中心的码头就是一个不错的拍摄点,非常适合同时拍摄日落和岛上标志性的灯塔。
东福山岛-日落-1

半隐。
东福山岛-日落-2

稍微放大一些的画面。
东福山岛-日落-3

想要看日出非常依赖天气,我只有第二次去的时候看到了,第一次只看到了早起的渔船在海上忙碌着。
东福山岛-日出-1

这次比较幸运,看到了美丽的朝霞。
东福山岛-日出-2

随后,太阳从海平线的云层中慢慢升起。
东福山岛-日出-3
东福山岛-日出-4
东福山岛-日出-5

太阳完全升起,但天边的云彩仍然残留着一缕朝霞。
东福山岛-日出-6

两个过于勇敢的人爬上了那块大石头。
东福山岛-日出-7

最后仍然用刚上岛就能看到的灯塔作为结尾吧。
东福山岛-日出-8

结语

随着独立开发的时间增加,几乎完全失去了与其他人交流的机会,旅行成了排忧解闷的主要方式。尽管效果越来越不明显,但我仍然决定下半年出去,走完今年十个国家目标中剩余的七个。

旅行 - 新加坡 - 越南

2026-03-21 20:13:55

回来一个星期了,才开始动笔写新加坡和越南的旅行。相比于呆了 24 天的马来西亚,新加坡和越南都只呆了 3 天,尤其是因为越南航空晚点 16 个小时,导致越南之行实际上只有 2 天时间,非常匆忙。

新加坡

从新山走陆路口岸通过,如预料之中,新加坡的电子化极其发达,包括海关都是直接刷护照即可自动通关,完全没有人工环节。公交或地铁也都可以直接刷 Visa 卡,尽管国内的 Visa 双币卡在国外就是废物一条着实坑了我,但仍然难以掩盖信息化的发达。

新加坡 - 市区

住在市中心附近,所以下午先去逛了会街,但大部分小店的商品总感觉是从义乌直接批发的。
新加坡 - 市区 1

走了一会就能看到地标建筑金沙酒店,三栋楼上面放着一艘船的样子。
新加坡 - 市区 2

沿着河边走,还能看到远处已经停运的巨型摩天轮。
新加坡 - 市区 3

偶然看到一个有趣的设计,通过一个凹面镜反射出周围的景色。
新加坡 - 市区 4

经典 CBD 照片,建筑密集度相当惊人。
新加坡 - 市区 5

去附近的商场吃个饭,似乎在下层还有一些给小孩子玩的设施。
新加坡 - 市区 6

之后出来继续沿河边散步,一个奇怪的 Apple Store 映入眼帘。
新加坡 - 市区 7

之后,就是经典的新加坡城市夜景了,稍微拍了几张。
新加坡 - 市区 8
新加坡 - 市区 9

新加坡 - MLP 联名花展

完全出乎意料的是还看到了 MLP 的联名花展 Floral Fantasy,这是万万没想到的,当即去看了。

在路边走时突然就看到了这个,当机立断买票就去了。
新加坡 - MLP 联名花展 1

门口的宣传把 Rarity 和 Apple Jack 放在后面也是日常了,她们算是 Mane Six 中人气最少的两个 Pony。
新加坡 - MLP 联名花展 2

进入之后看到 Celestia 和 Luna 时旁边一个女孩直接说 “妈妈,看,是 Celestia”,真是温馨的小场景。
新加坡 - MLP 联名花展 3

不过 Celestia 制作的确实太棒了!
新加坡 - MLP 联名花展 4

还有 Twilight 和 Rainbow Dash,制作的也很棒。

新加坡 - MLP 联名花展 5
新加坡 - MLP 联名花展 6

唯一糟糕的就是没有 Starlight,不过她终究不是 Mane Six,也是可以理解的。

新加坡-海边

第二天去了附近的教堂,巨大的清真寺,旁边有一条网红打卡街道。
新加坡-海边 1

下午前往了海边,尽管新加坡是一个繁忙的货运港口,但海水的质量看起来也非常棒。
新加坡-海边 2
新加坡-海边 3

旁边的公园还能看到不知名的小鸟。
新加坡-海边 4

第三天仍然出去玩,超级喜欢新加坡的绿化,这种绿色看着太舒服了。
新加坡-海边 5
新加坡-海边 6

新加坡-圣淘沙岛

所以下午又跑去圣淘沙岛看海去了。
新加坡-圣淘沙岛 1

悬索吊桥晃晃悠悠,还挺好玩的。
新加坡-圣淘沙岛 2

碧蓝之海,启动!
新加坡-圣淘沙岛 3
新加坡-圣淘沙岛 4

一个完全不知何故水平生长的椰子树,几乎都长到了海里。
新加坡-圣淘沙岛 5

乘坐这个轻轨,离岛乘坐公交车回去准备前往越南。
新加坡-圣淘沙岛 6

越南

越南-胡志明市区

刚下飞机,就感受到了强烈的共产主义国家的气息,宣传几乎无处不在。
越南-胡志明市区 1

殖民时期遗留的教堂,新增的大概只有胡志明的雕像?
越南-胡志明市区 2

傍晚的广场上人来人往的还挺热闹。
越南-胡志明市区 3

旁边就能看到胡志明市的最高建筑了。
越南-胡志明市区 4

这个压迫感有点引起 PTSD 了。
越南-胡志明市区 5

这里过马路有时只有斑马线没有红绿灯只能硬过也是体验到了。
越南-胡志明市区 6

越南-博物馆与统一宫

第二天前往博物馆看到的东西还算有趣。
越南-博物馆与统一宫 1
越南-博物馆与统一宫 2

之后去了市博物馆(统一宫),建筑本身还可以拍几张,只是房间内部的装潢有些过于宣传了。
越南-博物馆与统一宫 3
越南-博物馆与统一宫 4
越南-博物馆与统一宫 5

楼上能看到下面巨大的广场和远方的街道。
越南-博物馆与统一宫 6

总结

无论是新加坡还是越南,我都没能留下太多美好的回忆,可能我还是呆的时间太短了吧。但世事总是不如人意,希望今年还能继续走完七个国家,考虑到世界上有接近两百个国家和地区,就算每年走十个,也需要二十年才能完成,但这显然是一个值得去做的目标,至少对我来说是这样。