MoreRSS

site iconoldj | 老杰修改

男,80 后,杭州。阿里 8 年,目前在一个小而美的团队。「妙笔 / WonderPen」「ccReader 」作者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

oldj | 老杰的 RSS 预览

Electron 中的 Kiosk 窗口

2024-11-28 21:38:00

最近在产品中用到了 Electron 中的 Kiosk 模式,记录一下要点。

什么是 Kiosk 模式?

Kiosk 模式是一种专门为限制用户操作而设计的应用运行模式,通常用于构建锁定的全屏应用程序,禁止用户访问系统其他功能或退出应用。在这种模式下,应用程序占据整个屏幕,并且用户无法通过常见的方式(如键盘快捷键、窗口控制按钮等)退出或切换到其他应用。

Kiosk 模式的主要用途是为用户提供一个专注且受限的操作环境,避免对系统的其他部分产生干扰。

哪些场景下需要使用 Kiosk 模式?

Kiosk 模式被广泛应用于以下场景:

  • 公共信息亭:自助服务终端,如银行 ATM、自助点餐机、自助售票机。
  • 展览展示:在博物馆、展览会、零售店中,用于展示信息或广告内容的屏幕。
  • 教育场景:限制学生只能使用特定的教学应用,避免访问其他不必要的内容。
  • 会议或演讲:锁定演示内容,避免误操作或退出。
  • 数字标牌:作为广告屏幕或公告牌,循环播放内容。

当然,我在开发的是日常效率软件,并不属于以上场景。我用到 Kiosk 模式的场景主要如下。

图几截图软件

我开发并维护着一个截图软件图几,它有三种截图模式:全屏截图、窗口截图、区域截图。

其中区域截图的交互方式是:用户点击截图按钮(或按下截图快捷键),先生成当前屏幕的截图,随后显示一个全屏无边框窗口,在窗口中显示将刚刚生成的屏幕截图,同时允许用户在窗口上进行框选等操作。

这个无边框窗口就需要使用 Kiosk 模式,以免用户无意中切换窗口。当然,等用户完成或取消截图时,需要再退出或关闭对应的 Kiosk 窗口。

WonderPen 写作软件

WonderPen 写作软件最近添加了小黑屋模式,进入这种模式后,软件将全屏显示,屏蔽一切干扰,在完成预设的写作目标之前,将无法退出或切换到其他软件。

这个禁止退出的小黑屋,自然也使用了 Kiosk 模式。

Electron 中的 Kiosk 模式

在 Electron 中,将一个窗口设为 Kiosk 模式非常简单,在创建窗口时设置 kisok 属性为 true 即可。

有时,我们的窗口在创建时需要以普通模式显示,然后再在一定条件下切换为 Kiosk 模式,只需用类似下面的代码切换即可:

win.setKiosk(flag)

其中 flag 是一个布尔值。

你还可以使用 win.isKiosk() 方法判断当前窗口是否为 Kiosk 模式。

在实践过程中,我发现很多时候只设置 Kiosk 属性还不太够,还需要设置 frame 等属性。以下是一个示例:

const win = new BrowserWindow({
  // 其他属性...
  closable: false,
  maximizable: false,
  minimizable: false,
  resizable: false,
  fullscreen: false,
  fullscreenable: false,
  frame: false,
  skipTaskbar: true,
  alwaysOnTop: true,
  useContentSize: true.
  autoHideMenuBar: true.
  movable: false.
  thickFrame: false.
  titleBarStyle: 'default',
  paintWhenInitiallyHidden: false,
  roundedCorners: false,
  enableLargerThanScreen: true,
  acceptFirstMouse: true,
  kiosk: true,
  // 其他属性...
})

即使这样设置之后,在 macOS 上有时仍会出现 Docker 栏和顶部系统菜单栏出现在 Kiosk 窗口上方的情况,因此还需要进一步设置 alwaysOnTop 的属性为 screen-saver,代码如下:

win.setAlwaysOnTop(true, 'screen-saver', 1)

在 Windows 和 macOS 中,alwaysOnTop 的窗口有多种极别,按层级由低到高分别是:

  • normal
  • floating
  • torn-off-menu
  • modal-panel
  • main-menu
  • status
  • pop-up-menu
  • screen-saver

如果只是简单地 win.setAlwaysOnTop(true) ,则窗口的级别只是 floating,仍有可能被其他系统组件遮挡。

另外需要注意,在 macOS 下,太高的级别会挡住系统自带输入法的候选字窗口,如果你的 Kiosk 窗口需要用户输入,并且可能使用系统自带输入法的话,这个级别不能高于 modal-panel

一些其他注意点

Kiosk 模式只对当前窗口有效,一个窗口只能覆盖一个屏幕,若用户有多个显示器,则需先检测显示器数量,然后创建多个 Kiosk 窗口分别覆盖。

设置 Kiosk 模式后,用户仍可以使用 Cmd+Q 这样的快捷键退出应用,因此需要在代码中监听窗口的 close 事件,并检查是否处在 Kiosk 状态,如是则阻止退出。代码类似下面这样:

win.on('close', async (e: Electron.Event) => {
  if (win.isKiosk()) {
    e.preventDefault()
    return
  }

  // 其他逻辑
}

Windows 下退出 Kiosk 模式后,窗口的大小可能会变成全屏大小,如希望退出时恢复原大小,可以在进入 Kiosk 模式之前先记住窗口大小,退出后再设置为原大小。

Kiosk 模式并不能阻止用户重启计算机。如果希望重启计算机后能自动恢复 Kiosk 状态,可以将软件设置为随系统启动,并且启动时自动进入 Kiosk 模式。

使用 acme.sh 申请 SSL 证书

2024-09-23 10:57:00

之前很长一段时间,这个博客一直在用云服务商提供的免费 SSL 证书,那个证书有一年有效期,也即一年只需要申请部署一次,因此全手动操作也不算麻烦,但现在免费 SSL 证书的有效期统一缩短为 3 个月了,意味着每 3 个月就要操作一次,这就让手动申请和部署变得麻烦起来了。

最近,我尝试了一下使用 acme.sh 申请 SSL 证书的方法,确实方便了不少,在这里记录一下。

安装 acme.sh

acme.sh 是一个实现 ACME 协议的脚本,主要用途是申请或更新免费 SSL 证书。运行以下命令即可安装:

curl https://get.acme.sh | sh -s [email protected]

更多安装方式可见官方文档:https://github.com/acmesh-official/acme.sh

acme.sh 会被安装在 ~/.acme.sh 目录下。

手动申请证书

安装好 acme.sh 后,可以用以下命令申请证书:

acme.sh --issue --dns -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please

记得把其中的 mydomain.com 换成你自己的域名。

上面的代码中,我申请了泛域名证书,所以同时添加了 mydomain.com*.mydomain.com 域名。需要注意的是,*.mydomain.com 不包含 mydomain.com,如果你希望证书除了包含 www.mydomain.com 这样的二级域名,也包含 mydomain.com 的话,记得把 mydomain.com 也加上。

另外,*.mydomain.com 也不包含更深的层级,比如它包含 home.mydomain.com,但不包含 app.home.mydomain.com 。如果你需要更深层级的泛域名,需要把对应的域名也填上。

还需要注意的是最后一个参数 --yes-I-know-dns-manual-mode-enough-go-ahead-please 。acme.sh 更希望用户使用自动申请证书的方式(见下一小节),如果你确实需要手动申请,需加上这个参数,否则命令不会正常执行。

如果一切顺利,acme.sh 命令会输出两段 TXT 信息,需要你手动添加到对应域名的 DNS 解析中,以验证你确实对这个域名拥有权限。在证书申请完成之后,可以删除对应的 TXT 记录。

登录域名服务商(比如阿里云)后台,在域名解析中添加上对应的 TXT 记录,然后再运行以下命令,即可生成证书:

acme.sh --renew -d mydomain.com -d "*.mydomain.com" --yes-I-know-dns-manual-mode-enough-go-ahead-please

证书会被保存在 ~/.acme.sh/ 目录下,包含以下四个文件:

  • mydomain.com.cer 证书
  • mydomain.com.key 密钥
  • ca.cer
  • fullchain.cer 全链路证书

其中在网站场景主要使用 fullchain.cer 文件和 mydomain.com.key 文件。

自动申请证书

可以看到,上面手动申请的步骤,主要的手动操作就是要为域名添加 TXT 记录以验证域名权限,acme.sh 支持让这个步骤自动化,即自动添加 TXT 记录,并在验证完成之后自动删除对应的记录。

以阿里云为例(如果你的域名是在阿里云注册并解析的),首先需要去阿里云控制台获取一个 AccessKey,建议专门设置一个 RAM 用户,只开通 DNS 权限。

得到 AccessKey 之后,在命令行中执行以下命令:

export Ali_Key="key"
export Ali_Secret="secret"

随后再执行以下命令,即可自动申请或更新证书了:

acme.sh --issue --dns dns_ali -d mydomain.com -d "*.mydomain.com"

注意 --dns 参数后面的值为 dns_ali

一切顺利的话,证书申请会自动完成,并被保存在 ~/.acme.sh/ 目录下。

其他各大域名服务商的自动申请方式类似,具体可参见官方文档。

一些注意点

如果你使用了自动申请,AccessKey 会被明文保存在 ~/.acme.sh/account.conf 文件内,如果介意,可在申请完之后修改这个文件并删除对应的 AccessKey。

另外,使用自动申请后,acme.sh 会添加一条定时任务,每天自动检查证书是否需要更新。可运行以下命令查看当前系统的定时任务列表:

crontab -l

现在 acme.sh 默认使用的证书颁发机构是 ZeroSSL,还有一些其他可选机构,比如 Let's Encrypt。可以用 --set-default-ca 修改默认证书颁发机构,比如:

acme.sh --set-default-ca --server letsencrypt

我没有修改 CA,在使用默认的 ZeroSSL 的证书,目前来看暂时没有遇到什么问题。

除了自动申请证书外,大部分网络服务商也支持自动上传 SSL 证书,不过这部分我还没有研究,后续如果觉得值得记录,会另外写文分享。

处理苹果平台的 CONSUMPTION_REQUEST 消息

2024-08-09 16:31:00

最近完善了一下产品的购买流程,其中的一项工作是处理来自苹果 App Store 平台的 CONSUMPTION_REQUEST 消息,在这儿记录一下要点。

消息说明

App 如果使用了苹果的内购(IAP),每当发生用户购买、续费、退款等操作时,苹果服务器都会向开发者指定的地址发送一条消息,不同的消息有不同的 notificationType 值,其中 CONSUMPTION_REQUEST 消息的意思是用户为应用内购买发起了退款请求,App Store 请求开发者服务器提供用户的消费数据,用于协助 App Store 决定是否给用户退款。

开发者可以忽略 CONSUMPTION_REQUEST 消息,也可以根据需要,在 12 小时内回应 App Store。

回应消息

要回应 CONSUMPTION_REQUEST 消息,只需向指定的地址发一个 PUT 请求即可。具体细节可见官网文档

这个 PUT 消息的要点主要有两个:

  1. 在 Header 中添加认证 token 信息;
  2. 在 Body 中发送一个 JSON 格式的对象,向 App Store 提交对应的信息。

数据内容

我们先看 Body 中的数据内容。

根据文档,数据字段以及含义大致如下:

{
    "accountTenure": 0,  // 用户年龄段,0 表示未知
    "appAccountToken": "",  // 用户 uuid,由于之前没有设置,此处留空
    "consumptionStatus": 0,  // 消费状态,0:未知,1:未消费,2:部分消费,3:全部消费
    "customerConsented": True,  // 用户是否同意提供消费数据
    "deliveryStatus": 0,  // 交付状态,0:已成功交付
    "lifetimeDollarsPurchased": 0,  // 用户在应用内购买的总金额,0 表示未知
    "lifetimeDollarsRefunded": 0,  // 用户在应用内退款的总金额,0 表示未知
    "platform": 1,  // 平台,0:未知,1:苹果平台,2:其他平台
    "playTime": 0,  // 用户在应用内的总时间,0 表示未知
    "refundPreference": 1,  // 商家对退款的意见,0:未知,1:支持,2:不支持,3:不确定
    "sampleContentProvided": True,  // 是否已经提供了示例内容
    "userStatus": 1,  // 用户账号状态,0:未知,1:活跃,2:暂停,3:关闭,4:受限
}

你可以根据需要,修改对应字段的值。

请求 Header

请求 Header 中有两个必填的自定义字段,分别是:

  • Content-Type 值固定是 application/json
  • Authorization 值为 Bearer $jwt_token

其中 jwt_token 必须要正确填写,否则请求会返回 401 错误。

jwt_token 的具体生成说明可见官方文档,大致格式类似下面这样:

Header:

{
  "kid": "ZA12345678",
  "alg": "ES256",
  "typ": "JWT"
}

Payload:

{
  "iss": "your_uuid",
  "iat": 1723173620,
  "exp": 1723183620,
  "aud": "appstoreconnect-v1",
  "bid": "your_bundle_id"
}

其中 kidiss,以及生成 JWT 时所需的私钥等几项,需要去 App Store Connect 后台生成。

JWT 私钥

如果你之前还没有生成过对应的私钥,可以前往 App Store Connect 后台的“用户和访问” → “集成” → “App 内购买项目”页面生成,如下图所示:

生成之后,可以在这个页面下载 .p8 格式的私钥。注意这个私钥只能下载一次,下载之后请妥善保存,如果不慎遗失,只能删除再重新生成一个。

上面生成 JWT 所需的 kid 对应上图中的“密钥 ID”,iss 对应“Issuer ID”,私钥即上面下载的 .p8 文件中的内容。

然后就可以用类似下面的方法生成 JWT 了:

import jwt

jwt_token = jwt.encode(
    payload,
    private_key,
    algorithm="ES256",
    headers=headers,
)

最后,将得到的 jwt_tokenBearer $jwt_token 的形式包含在请求头的 Authorization 中,发起 PUT 请求即可。

如果请求返回 202 状态码,表示请求成功了。如果是其他值,可根据错误状态再仔细检查处理。

在 Flutter 中适配 1Password 登录

2024-06-27 14:34:00

最近在开发 Flutter 项目,其中 iOS 版 App 账号登录时,需要适配 1Password 等密码管理器,即需要告诉 1Password 等密码管理器当前 App 的登录特征信息(域名),以及应该填写界面上的哪些表单项。在这儿记录一下要点。

基本设置

我们首先要处理的,是让 App 和某个域名(通常是官网域名)关联,这样在 App 中唤起 1Password 填写密码时,1Password 才知道应该显示哪些账号。

这儿主要有三个步骤。

Apple 开发者后台设置

在 Apple 开发者后台的 Certificates, Identifiers & Profiles 页面,记得要选中 Associated Domains 选项,如下图所示:

Xcode 中的设置

接下来,要在 Xcode 中为你的 App 添加关联域名,如下图所示:

在 Domains 那一栏,添加 webcredentials:你的域名 即可,比如你的域名是 test.com,那么添加 webcredentials:test.com 就行。

网站设置

最后,还需要在你的域名对应的网站上添加一个认证文件,证明指定 App 确实和当前域名相关。这个文件的文件名固定为 apple-app-site-association,可以放在网站的根目录,或者 .well-known 目录下,确保可以通过网络访问到。

这个文件的用途很多,可能还会包含一些其他字段,和密码管理器相关的主要是以下内容:

{
  "webcredentials": {
    "apps": [
      "TeamId.BundleId"
    ]
  }
}

确保你的 apple-app-site-association 文件包含 webcredentials 字段,并将其中 apps 中的 TeamId、BundleId 换成你的真实 ID。

Flutter 中的设置

为了得到更好的登录体验,Flutter 中也要做一些设置,主要是告诉 1Password 等密码管理器需要填写哪些字段,以及各个字段分别对应什么内容。

关键代码如下:

@override
Widget build(BuildContext context) {
  // ...
  return Container(
    body: Center(
      child: AutofillGroup(
        child: Column(
          children: [
            TextField(
              autofillHints: const [AutofillHints.email],
              decoration: InputDecoration(
                labelText: 'Email',
              ),
            ),
            TextField(
              autofillHints: const [AutofillHints.password],
              decoration: InputDecoration(
                labelText: 'Password',
              ),
            ),
            ElevatedButton(
              onPressed: () {
                // Submit the form
              },
              child: Text('Submit'),
            ),
          ],
        ),
      ),
    ),
  );
}

其中最关键的有两处,一是需要自动填写的表单部分,需要用 AutofillGroup 组件包起来,这样 1Password 就知道哪些字段是需要自动填写的。二是 Email、用户名、密码等需要填写的字段,需要添加 autofillHints 属性,比如 autofillHints: const [AutofillHints.email],这样 1Password 才知道当前字段应该填什么内容。

完成这些设置之后,App 登录时就应该能正常适配 1Password 了。

从打牌想到的

2024-04-21 14:17:00

最近几个月打了很多次牌,有时是线下聚会时和朋友玩,有时则是在手机上玩。玩得久了,逐渐发现扑克牌游戏和现实生活中的规则有一些类似之处。以下是一些感想。

手中的牌

打牌时,手中拿到什么牌非常重要,这一点很容易理解,无论你是高手还是菜鸟,如果起手就拿到一手好牌,那么只要不乱打,并且运气不是差到极点,你基本上都能赢。同样的,就算你是绝顶高手,如果拿到一把烂牌,要赢恐怕也非常难。

当然,拿到极品好牌和极品烂牌的概率都不大,很多时候,我们以及我们的对手拿到的都是中等牌,这种时候,如何组合手中的牌打出最好的效果,就看各自的技术了。

我们的人生也类似,总有一些人一开始就拿到一手好牌,比如家境良好,父母见识不凡,自身也健康聪明,因而只要自己不走错路,人生总体上会非常顺利。还有一些不那么幸运的人,出生在落后的地方贫穷的家庭,几乎没有什么可利用的资源或者助力,要获得成功可就不容易了。

拿到好牌时,不要得意忘形,因为你的成功很大程度源于运气。拿到烂牌时,也不要破罐子破摔,认真思考,尽最大努力打好手中的牌,因为只有这样你才能多一点获胜的机会。

合适的才是最好的

摸牌的时候,一般来说摸到大牌比摸到小牌更好一些,不过稍有经验就会发现,和单独的大牌相比,那些能让现有的牌组合起来的牌可能更好。比如,有一些时候,摸到一张最小的 2 可能会让你的几张散牌组成同花顺,这时对你而言 2 就比大王更好。

生活和工作中也是类似,有一些团队,单独来看每位成员可能都相对普通,但由于配合出色,于是团队整体的战斗力非常强悍。

团队招人的时候,也不是招越牛的人越好,而是要看新来的人能否让团队的整体能力得到提升。有时候,也许加入一位履历一般但却能搞定一些其他人不擅长处理的小事的成员,会让团队整体焕发新生。同样的,一位看似不重要的成员离开,也有可能打断团队内部的某种连接,让团队效率大受影响。

寻找人生伴侣也是如此,那些光彩夺目的潜在选项当然也不错,但一位能与你互补,让你成为更好的自己的伴侣,也许是更好的选择。

本钱

拿到什么牌很大程度上能决定你单局的胜负,有多少本钱则决定你能在牌桌上待多久。

以腾讯欢乐斗地主的掼蛋游戏为例,新手场单场输赢封顶是 5 万欢乐豆。即如果你手中的欢乐豆不足 5 万,那么你赢的时候手中的欢乐豆翻倍,输的时候会赔得精光。当然,实际上输赢的数额还取决于对手的豆子够不够,不过为了简化讨论,此处我们假设对手总是有足够的豆子。

这个规则很容易理解,而且看起来似乎也很公平。但在玩了很多场之后,我发现这其实是一个对水平普通且本钱较少的玩家非常不友好的规则。

假设你是一位普通水平的玩家,即你的水平处在中游,每局有 50% 的概率赢,也有 50% 的概率输,并且初期你只有 1000 欢乐豆,你会面临什么情况呢?

当你的豆子数量在 5 万以下时,无论你之前赢了多少次,只要输一次,你的豆子就被清空了。也就是说,想要让自己的豆子超过 5 万,你需要连赢 \(6\) 次,才能让自己的豆子从 1000 增长到超过 5 万的数量(\(1000 \times 2^6 = 64000\))。但是,由于你的赢率只有 50%,因此连赢 6 次的概率也只有 \(\frac{1}{2^6}\),即 \(\frac{1}{64}\) 或者 1.56% 。

但即使你有了 6.4 万豆子,你仍然不安全,只要连输两次(发生的概率为 \(\frac{1}{4}\) 或 25%,这并不是一个小概率),你就又会回到一无所有的状态。要让自己更安全一些,你可能需要至少 20 万豆子,这样只有连输四次(发生的概率为 \(\frac{1}{16}\) 或 6.25%),你才会输光所有豆子。而要从 1000 豆子变成 20 万豆子,你需要先连赢 6 次,然后在接下来保住本金的情况下再净赢至少 3 次。

粗略计算一下就可以知道,作为一名赢率只有 50% 的中等水平玩家,取得这个成就的概率不足 1%。

另一方面,如果你仍然只是一名中等水平的玩家,但你已经有 20 万甚至更多的豆子了,会怎么样呢?

由于你的输赢概率各一半,并且每次输赢都只是增减 5 万欢乐豆而不是一下子输光所有,因此你的账户余额从概率上来讲将会一直持平。期间可能会因为随机因素出现一些波动,甚至有非常小的概率你在某次大波动中余额被击穿从而破产,但更大的概率是你的资产经过波动之后又回归到初始值附近。

换一句话来说,对一名中等水平玩家来说,从底层逆袭的成功概率极小,因为在初期他需要连赢很多次才行,同时只要输一次就会完全失败。但同样水平的玩家,如果一开始就拥有大量本钱,他却有很大的概率能守住这些财富,因为他不怕输,即使输了也仍然有机会赢回来。也即本钱越少,越不容易出头,财富越多,则越容易守住财富。

这大概也是阶级固化的一种解释吧。

读《星震》

2024-03-08 16:50:00

科幻小说《星震》是《龙蛋》的续集,讲的是人类造访一颗名为龙蛋(也叫蛋星)的中子星,行程结束准备离开时,蛋星发生了一场星球级别的大地震(星震),星球上的奇拉文明几乎毁灭,随后奇拉与人类相互帮助、相互拯救的故事。

读这部小说之前,最好先读过前作《龙蛋》,否则对故事的背景可能会难以理解。在继续写这篇读后感之前,也让我们先回顾一下《龙蛋》的前文提要以及设定。

蛋星是一颗由比太阳还要大的红巨星坍塌而成的中子星,直径只有二十公里,因此星球上的物质密度极高,引力也极为强大,根据书中的设定,蛋星表面的引力强度是地球上的 670 亿倍!

在这种环境下,蛋星上物质的形态与地球上常见物质的形态自然大不相同,那儿的各种活动不再依赖分子级别的化学反应,而是速度更快的核子级别的物理反应,于是对那颗星球上的智慧生命奇拉而言,它们的时间比我们快 100 万倍。所以,在前作《龙蛋》中,人类刚与奇拉们相遇时,奇拉们还处在石器时代,但一星期后,奇拉们的科技就已经远超人类。

然而,就在人类基本完成考察准备离开时,一场灾难突如其来,星震发生了,蛋星上的奇拉文明遭受了毁灭性的打击,无数奇拉死去,大量建筑和设备报废,只有少数幸运儿活了下来,但他们几乎不会操作和修复那些先进的设备。

不幸中的万幸是当时蛋星上空还有一些正在太空中执行任务的奇拉,这些太空奇拉受过良好的教育,依然掌握着部分先进技术,它们是奇拉恢复文明的希望。但问题是,地表的降落场地都已经在星震中被毁坏了,他们无法降落,只能先设法联系上地表的奇拉,并远程指导他们修复降落场。

一开始,一切都很顺利,但不久之后,地表上一些原本处在社会边缘的奇拉有了其他心思,他们觉得现在这种没有法律可以随心所欲的灾后世界似乎也挺好,如果让太空奇拉降落,他们是不是又要回到从前那种处处受限的状态?

一番争斗之后,这些“坏”奇拉赢了,他们建立了新的秩序,蛋星的地表文明退回了蛮荒时代。太空奇拉无法降落,只能另想办法,而唯一的办法可能会让正准备离开的地球人处于危险之中……

最后,在太空奇拉、地表“好”奇拉以及人类的共同努力以及牺牲之下,他们终于重建了文明,而且在 100 万倍的时间流速下,很快就达到了比之前更高的高度。

整个故事对人类而言都是在一天之内发生的,但对奇拉而言却已经过去了很多代人。

比较有意思的是故事中没有一个贯穿始终的超级英雄个体,很多奇拉在为恢复文明而努力,某位奇拉在某个阶段出场很多,正当你以为 Ta 是主角时,Ta 却可能突然因为意外或战争“流逝”(故事中奇拉的死亡被称为“流逝”)了。这种安排可能会让期待超级英雄的读者读起来不那么“爽”,但掩卷细思却也觉得非常合理,因为他们面临的是星球级别的灾难,在这种灾难面前个体的力量是渺小的,只有所有人合力,一起向着一个方向前赴后继地努力才有可能取得最后的成功。

故事中也探讨了一些科幻作品中常见的问题。比如,科技文明的诞生是必然吗?作者认为并不是,故事中作者借一位奇拉的思考表达了这样的观点:蛋星上的奇拉们虽然很早就有了智慧,进入了原始文明,但如果不是人类飞船的造访和影响,也许奇拉会继续在低水平的文明中徘徊漫长时光。而如果在星震发生的时候,他们仍然没能发展到可以进入太空,那么迎接他们的将会是一场更彻底的倒退甚至毁灭。

另一个问题是,奇拉文明的科技水平已经远远超过了地球文明,为什么他们没有入侵或者消灭地球文明?故事中也给出了解释,因为奇拉文明是中子星文明,由普通物质组成的地球世界在他们眼中几乎是空的,太阳系的资源对他们来说也几乎毫无用途,换句话说就是他们和地球文明处在完全不同的生态位上,没有竞争关系。做一个不太恰当的类比的话,奇拉文明和地球文明就好像狮子和野草一样,两者所需的资源差异极大,又对彼此都没有威胁,因此完全可以和平共处。如果奇拉文明遇到另一个中子星文明,或许就不会如此和谐了。

还有一个经常出现在科幻作品中的问题是,为了活下去,我们可以吃同类的尸体吗?故事中奇拉文明和人类文明有着完全不同的道德观和选择,对奇拉文明而言这根本不是一个问题,因为在他们的文化中吃死去同类的尸体是很正常的事,即使在不缺食物的情况下,同类的尸体仍然是他们喜爱的美食,甚至宴会中最好的菜可能就是一份烤奇拉。但对地球文明来说,这却是让人难以接受的事,故事中地球人类遇到这个困境时,果断拒绝了这个选项,宁可饿死。

在本书的最后,作者还描写了奇拉文明中的一些细节,有一个细节我觉得挺有趣:奇拉文明中的建筑都是没有房顶的。

为什么会这样?作者解释说因为蛋星的引力太大了,加上大气稀薄(蛋星上的大气由电子、铁离子或其他典型星壳核子组成),因此自然界中从来没有进化出会飞的生物,大概也没啥能跳得很高的生物,于是他们的世界基本上是二维的,几乎从来不会有麻烦来自上方,所以他们只需要建四面墙把自己围起来就安全了,不需要屋顶。

总的来说,这是一部有趣的硬核科幻小说。