普通视图

7 刀 VPS 照样玩转 Clawdbot (Moltbot)

2026年1月28日 23:16

最近科技圈最火的莫过于 clawdbot (现改名为 moltbot),这是一个能帮你操作设备的 AI 助理。与 claude code 这些 AI 工具不同,它能真正“动手”,直接控制设备、读写文件、管理邮件、日历,甚至通过即时通讯软件主动提醒或汇报,不仅限于聊天或编程。

虽然 clawdbot 听上去很高大上,但配置其实非常简单。我使用一台年费不到 7 刀、配置相当“辣鸡”的 VPS(仅 1 vCPU, 2G RAM)也能部署成功。这篇文章就来分享一下这个极简的部署教程。

安装前提醒

⚠️ 注意:虽然 clawdbot 功能强大,但其本质仍处于早期开源阶段,可能存在安全风险,不建议在生产设备上部署,这也是最近 Mac mini 卖到脱销的原因之一。因此,选择一台廉价 VPS 或者闲置设备进行体验是一个成本更低、更安全的选择。

安装教程

一键安装

  1. 通过 SSH 进入 VPS 后台,输入以下指令开始安装:
curl -fsSL https://molt.bot/install.sh | bash
  1. 当系统提示 I understand this is powerful and inherently risky. Continue?,选择 Yes,否则会退出安装流程。
  2. 如果不慎退出,可以输入 clawdbot onboard 可再次进入安装流程。
  3. Onboarding mode 选择 Quickstart

选择模型

  1. Model/auth provider 中选择你偏好的模型并输入 API 即可。官方推荐 Minimax,但如果你不想为模型付费,且希望配置更简单,建议选择 Qwen
  1. 选择 Qwen 后,命令行会给出一个授权链接。将链接复制到浏览器,登录 Qwen 账号完成授权。
  2. 然后选择 Keep current (qwen-portal/coder-model)

配置 Telegram bot

  1. select channel 选择 Telegram (Bot API)
  2. 按照提示,在 Telegram 中搜索 @BotFather,发送 /newbot,根据引导填写名称和用户名。创建成功后,将获得的 API Token 粘贴回 VPS 命令行即可。
  3. 当然你也可以选择其他渠道

配置 Skills 和 Hooks

  1. Skills 可以按需选择,也可以先跳过,后续再手动安装,我选择了不安装。
  2. Hooks 提示的三个选项建议全选。

检查是否安装成功

安装完成后,输入 ss -lntp | grep 18789,如果有返回内容,则说明服务已成功启动。

完成 Telegram 最后配对

  1. 打开刚才创建好的 Bot,发送 /start
  2. Bot 会返回一条形如 clawdbot pairing approve telegram <code> 的指令。
  3. 将其中的 <code> 替换为 VPS 命令行显示的 Pairing code,然后将整行指令通过 ssh 发给 VPS 即可完成配对。
  4. 然后现在对 Bot 输入任何内容,如有回复,则说明部署成功。

安装 skill

如果在安装时跳过了 Skill,或者想尝试新的 Skill,可以直接将对应 Skill 的文档链接发给 Bot,让它“自学”并自动帮你安装。

防火墙关闭 18789 端口

因为通过 http://ip:18789 可以直接访问该服务,为了安全,在 VPS 上部署好 clawdbot 后,建议不要对外开放 18789 端口

因 VPS 情况不同,具体操作也有所不同。如果是腾讯云、阿里云这种大厂的虚拟机,可以在平台网站的后台去设置。如果和我一样,安装了 1Panel,可以直接在 系统防火墙 处设置端口。

内存占用

最后安装完成后,clawdbot 内存占用在 400 MB 多一点。

但如果内存过低,可以在 VPS 上干的事情太少,也就失去了意义。

最后

虽说在 VPS 上部署 Clawdbot 的成本极低,但受限于硬件配置,运行流畅度确实不如本地设备,只能说是“勉强够用”的水平。

如果你追求更丝滑的体验,建议选择配置稍高一些的 VPS(2C2G 以上为佳),或者干脆利用家里闲置的旧电脑。

Clawdbot 虽然风靡一时,但在折腾之前,建议先想清楚自己是否有真实的应用场景。工具虽好,别为了折腾而折腾


廉价 VPS 选择

Dedirock

这就是我用来布置 clawdbot 的 VPS(第三款),除了便宜一无是处。有人说很不稳定,但我自己从黑五用到现在,还没出现啥问题。总之不建议用来运行正经服务,适合折腾,出事了也不心疼。

内存核心数空间流量带宽价格购买链接
2.5 GB1 vCPU15 GB2 TB/月1 Gbps$6.45 /年购买链接
2 GB1 vCPU15 GB2 TB/月1 Gbps$6.59 /年购买链接
2 GB1 vCPU30 GB2 TB/月1 Gbps$6.75 /年购买链接

RackNerd

我的第一台 VPS 就是购买于他家,虽然线路没有针对中国进行优化,但是价格便宜,配置也还不错,适合新手小白练手使用。比 Dedirock 靠谱些

如果需要购买,地区尽量选择 DC02,其次是圣何塞、DC03。

内存核心数空间流量带宽价格购买链接
1 GB1 vCPU20 GB SSD4 TB/月1 Gbps$10.29 /年购买链接
2.5 GB2 vCPU45 GB SSD3 TB/月1 Gbps$18.66 /年购买链接
4 GB3 vCPU65 GB SSD6.5 TB/月1 Gbps$29.98 /年购买链接

7 刀 VPS 照样玩转 Clawdbot (Moltbot)最先出现在Jack's Space

XA Note - 小A笔记

2026年1月15日 14:58

心血来潮,总得干些什么,在AI如此强大的时代,不借助它做点什么总觉得少了点什么,如此便有了 xa-note

项目地址:https://github.com/awinds/xa-note

它是一款借助 AI 编程开发的个人笔记系统,介绍如下 (点个Star):

XA Note 是一款轻量级、可完全自托管的个人笔记系统,由您自行部署和管理,专为注重隐私、安全与可控性的用户设计。
系统支持 Markdown 编辑、分类管理、标签系统和全文检索,提供流畅的写作体验与清晰的知识结构。

可以多种部署:

  • 支持 Cloudflare-Pages 部署,将白嫖进行到底,完全免费,部署完毕得到 yoursname.pages.dev 这样二级域名,你的应用,数据库都直接使用Cloudflare的免费服务。
  • 支持 Docker 部署,一键部署,然后通过 nginx 反代访问

注册美区 Apple ID 越来越难,不到 48 小时就“翻车”

2026年1月1日 00:47

前段时间,关于注册美区 Apple ID 的话题又火了起来。作为一名使用了多年美区账号的老用户,我本着求真务实的态度,决定亲自验证一下当下注册美区 ID 的可行性。虽然借助安卓 Apple Music 顺利注册成功,但正当我文章写到一半时,一件意想不到的事情发生了 — 我刚刚注册成功的账号不到 48 小时就“喜提”封号。于是我决定放弃原本侧重“教学”的内容,改为分享这次翻车经历,希望能给想要注册美区 Apple ID 的朋友们提供一点参考。

前情提要

本人已经拥有一个使用长达 10 年以上的美区账号,并将其作为主账号使用超过 5 年。此次注册纯属周末闲来无事,想验证一下“官网直接注册”和“安卓 Apple Music 间接注册”两种方式目前是否可行,并顺便水一篇文章。虽说最终翻车,但对我个人并无实质影响。

为什么需要一个美区 Apple ID

对于大多数人来说,拥有国区 Apple ID 就足够日常使用了,但如果你有以下需求,就需要准备一个美区账户:

  1. iCloud数据不存储于云上贵州,数据更安全,且使用体验几乎无差别;
  2. 可以体验更完整的苹果服务,如 iCloud+、Fitness+ 等;
  3. 美区 iCloud+ 包含更多高级功能,例如自定义域名邮箱(我现在使用的 jack@veryjack.com 就是基于此);
  4. 可以下载国区没有的应用,比如 ChatGPT、Netflix、TikTok等;
  5. 可以下载国际版游戏或应用,比如王者荣耀国际版。
  6. ⚠️ 现在的注册门槛越来越高,渠道也越来越少。如果还能注册,建议尽早弄一个“养”起来,有备无患。

美区 Apple ID 也有很多不便

  1. 支付麻烦,无法像国内那样直接绑定支付宝或微信,国内信用卡也不通用;
  2. 缺少本地化应用,比如抖音、王者荣耀国内服、QQ音乐等;
  3. 价格普遍偏高,国内内购最低 1 元起步,美区则是 1 美元起步,Apple Music 美区版本是 10.99 美元/月,国内是 11 人民币/月。
  4. 虽可与国区账号组成家庭,但有诸多限制,通常只能共享 iCloud 存储空间,无法共享订阅服务。

注册经历

⚠️ 以下是我的注册经历,虽然最终翻车被封号了,但这只是个例,并不能代表该方法完全失效,还是有网友反馈成功注册(最后有没有封号就不得而知了)。

官网直接注册

首先,我尝试了官网直接注册

  1. 进入官网 account.apple.com,点击右上角直接创建新的账户
  2. 邮箱我使用的是 outlook。
  3. 地区选择美国,手机号选择 +86 中国。
  4. 接着需要输入邮箱验证码和手机验证码。如果足够幸运,可以直接开通成功,但在我的测试中,会卡在手机验证码阶段,提示“account cannot be created at this time”。
  5. 期间我分别尝试了国内 IP 和美国 IP ,均不成功,只能放弃。

安卓 Apple Music 间接注册

接着,我尝试了间接注册法,这需要一台安卓设备和苹果设备。

  1. 安卓手机安装 Apple Music,在登录时选择创建新账户,地区选择美国。此时只需要验证邮箱,即可初步创建帐户。
  2. 随后,我拿出闲置的旧 iPhone 8 (原先登录的是国区 ID ),打开 App store,点击右上角头像,往下滑到底部,退出登录。然后登录了新注册的美区 Apple ID 。这时候会提示输入手机号码,选择 +86 中国的手机号(和前面失败时用的是同一个号码),结果顺利通过验证,并免费下载了一个 TikTok 。
  3. 完成以上步骤后,一个能下载免费应用的美区Apple ID就创建成功了。
  4. 并且全程使用中国 IP。

修改付款方式和账单地址

为了后续能充值礼品卡及购买应用,我又登录网页端进行了设置:

  • 将付款方式设为“无”;
  • 并将账单地址改为免税州俄勒冈(在地图上随便找了个真实商铺地址填进去)。

至此,除了充值礼品卡之外的所有操作都已完成,当时还觉得过程还挺顺利的。

48 小时内的封号

当我将手机闲置 24 小时再次拿起时,发现 iPhone 8 提示需要验证密码,就知道“大事不妙”。果然,输入密码后提示无法登录。我随即尝试登录网页端验证,提示需等待 24 小时审核。第二天便收到了邮件通知:“我们已审核并拒绝访问该账户” 😢 。

复盘总结

虽说是总结,但我其实并不清楚具体是哪个环节触发了风控。反思下来,原因可能有以下几点:

  1. 注册时使用的 IP 与账号所属地区不符;
  2. 使用 +86 手机号注册美区账户可能更容易触发风控;
  3. 注册完成后没有后续动作,被苹果系统判定为“僵尸账号”;
  4. 其他暂时未知的风控机制;
  5. 旧手机+外区新 ID 非常容易被封,新手机反而没事(网友反馈)。

避坑建议

  1. 新注册的账户不要立即充值礼品卡,以免造成金钱损失;
  2. 新注册的账户也不要立即作为主账户,并开启“查找 iPhone”功能,避免封号后无法从设备上登出;
  3. 新账号可能需要一段观察期,建议保持一定的日常使用量;
  4. 或许立即开启二次验证等提高账号安全性的操作,能稍微降低被风控的概率;
  5. 如果被封号,可以去联系客服(在线或者电话)尝试要求解封;
  6. 新外区号注册完应该等两星期再用,PayPal 也是这样,注册完等一阵再绑定 Apple 不容易被封(网友反馈);
  7. 新设备注册账号封号概率更低(网友反馈);
  8. 如果有美国的朋友,可以让朋友帮你注册,避免封号(网友反馈)。

支付建议

如果你有幸成功注册,且账号顺利存活下来,如果打算购买应用或服务,一些支付方面的小建议:

  1. 建议不要去闲鱼购买礼品卡,如果对方使用黑卡购买,你的账号也会被牵连而被封号;
  2. 礼品卡建议直接去苹果官网购买 https://www.apple.com/shop/gift-cards,国内 Visa 或者 Mastercard 可用,其次可以考虑支付宝平台;
  3. 谨慎绑定虚拟卡,有可能导致封号;
  4. 有条件可以使用美区 PayPal,但也要注意养号,不要高频交易等可疑行为避免被风控。

最后

希望这次“翻车”经历能给大家提供一些参考,助大家在账号申请的过程中少走弯路,避开那些不必要的坑。

作为一名数字游民,在这些年的“折腾”中我深有体会:国内用户使用国外服务的门槛正变得越来越高。不仅是注册美区 Apple ID,就连 PayPal、Claude 甚至 Google 邮箱的申请难度也在不断攀升。从早期的随意注册,演变为如今对原生 IP、海外手机号、地理定位、实体 SIM 卡甚至境外支付方式的严格要求。

因此,我现在养成了一个坏习惯,每当有机会注册某个国外服务时(比如前阵子很火的 Wise),无论当下是否用需要,我都会先占个坑,以防日后门槛进一步提高导致无法注册。

技术的初衷本应是消除边界,共同进步,但现实却是各国各公司都在修筑围墙。在宏大的国际博弈之下,个人的努力显得微不足道,那些“提前占坑”的行为,更像是一种面对未知的无力抵抗。我担心,未来的某一天,普通人会彻底失去与国际服务、与广阔世界对话的可能。如果那一天注定会到来,那么现在所有的“未雨绸缪”,或许就是我们普通人为了不与世界彻底失联,做的最后一点挣扎。

致谢

在正式发布本文之前,我先将事件经过分享到了 V2EX。非常感谢 V2EX 网友们分享的宝贵经验,感兴趣的朋友也可以移步原帖,查看更多讨论与评论:《新注册的美区 Apple ID,不到 48 小时就“翻车”了》

注册美区 Apple ID 越来越难,不到 48 小时就“翻车”最先出现在Jack's Space

使用腾讯云云迁移服务将 R2 存储中的文件迁移到腾讯云 COS 中

2025年12月15日 21:34

最近把这个域名重新备案了一下,就可以利用起我在腾讯云上的闲置服务器。既然要迁移服务器,不妨将图床一并迁移,这样后续使用起来也方便,国内的读者加载起来速度也快。

不过,这些年大量使用,我的文件还是挺多的….足足有 13GB 的文件,手动一个个搬迁可就累死了;于是乎,我决定试试腾讯云的迁移服务,来帮助我把 R2 上的文件迁移过来。

image

获取配置信息

想要使用腾讯云提供的云迁移(CMG)服务,则需要获取一些配置信息,具体包括:

  • Cloudflare R2 的 Access ID 和 Secret Key
  • 腾讯云的 Access ID 和 Access Key,创建好的 Bucket(要迁移的目标)

R2 的相关配置可以在 CloudFlare R2的配置页面找到;如果没有的话,你就创建一个新的。

image

腾讯云的则可以在腾讯云密钥管理中获取,建议创建一个新的用户,并授予 QcloudMSPFullAccessQcloudCOSAccessForMSPRole 策略,点击子账号可以看到如下图的两个权限。

image

配置云迁移

完成账号的确认后,接下来就是配置云迁移。打开云迁移中的「对象存储迁移」,或者直接打开这个链接,就直接进入云迁移的页面。

image

源站配置

接下来配置云迁移的具体配置,点击新建人数,在新的页面中,输入你的 CloudFlare 配置信息,具体可以参考下面的截图:

image
  • AK/SK: 你从 Cloudflare 获取的相关参数;
  • 桶名称:你的 R2 Bucket 的名称;
  • 空间域名:你的 R2 的域名,是 uid.r2.cloudflarestoage.com,比如我的是 https://24071135c3ad9d9196e7e45e33948d28.r2.cloudflarestorage.com
  • 桶的所在地:比如我的是亚洲,就选 apac

源站中的其他选项可以根据需要选择,如果你是完整迁移,和我保持一致即可。

目标站点配置

接下来是配置迁移目标,这里指标支持迁移到腾讯云自家的 COS 上;填入你的 Secret ID 和 Secret Key,然后可以直接在下面输入具体的 Bucket 名称,或者填完后点击下拉框右侧的刷新按钮后,选择合适的。

image

其他的选项,如果你和我一样是整个 Bucket 迁移,则可以保持相同的配置,直接整个迁移。

配置完成后,点击最下方的新建并启动,就会启动搬迁。接下来就回到任务列表等刷新即可,等待他自己搬迁完即可。实测搬迁速度很快,13G 的文件,8 分钟就搬迁完成了(还是我限制了搬迁的带宽),如果是不限制,估计 2 分钟就能搬迁完成。

image

如果你需要和我一样,从外部的 S3 将文件搬迁到腾讯云的 COS 上,不妨试试看这个方法~

Docker 部署一个精美的画廊 —— ChronoFrame

2025年12月8日 22:30

如果你热爱摄影,又有一定折腾能力,那么或许也希望拥有一座属于自己的精致在线画廊,用来与他人分享作品。近期在一位网友的推荐下,我了解到 ChronoFrame —— 一款开源、自托管的个人云相册解决方案。借助 Docker,即便是新手也能够相对轻松地部署属于自己的独立画廊网站。

效果展示

我的网站(不打算长期保留):https://gallery.veryjack.com

官方示例:https://lens.bh8.ga

优势

  • 自部署,数据属于你自己
  • 优雅的浏览体验
  • 多格式兼容,甚至支持 Live photo

需要什么

  • 准备一台 VPS(NAS 也可以)
  • 一个域名(可选,但建议有)
  • 若干照片

项目地址

https://github.com/HoshinoSuzumi/chronoframe/blob/main/README_zh.md

开始部署

我们采用 docker compose 的方式进行部署,这样最简单,也方便后期管理。一共需要配置两个 docker-compose.yml.env 文件。

你可以通过 ssh 连接上服务器,然后 cd /path/to/chronofram_folder 进入chronoframe 项目文件夹下,直接创建,具体步骤不再赘述,会命令行的用户自然知道怎么做。

  1. 对于小白,大概率会部署 1panel 或者 宝塔面板,我建议可以直接通过面板,进入项目目录下,直接创建并配置这两个文件即可。比如我用的 1panel,文件 下直接进入 /root/data/docker/chronoframe 文件夹下,然后点击 创建 按钮,新建两个文件,命名分别为 docker-compose.yml.env
  1. docker-compose.yml 文件中填入以下信息
services:
  chronoframe:
    image: ghcr.io/hoshinosuzumi/chronoframe:latest
    container_name: chronoframe
    restart: unless-stopped
    ports:
      - '3000:3000'
    volumes:
      - ./data:/app/data
    env_file:
      - .env

其中 '3000:3000'第一个 3000 可以改为任意你喜欢的且没被占用的端口号,配置好后,确保防火墙有放行该端口号。

  1. .env 文件中填入以下信息
# 管理员邮箱(必须,默认 admin@chronoframe.com)
CFRAME_ADMIN_EMAIL=example@mail.com
# 管理员用户名(可选,默认 ChronoFrame)
CFRAME_ADMIN_NAME=Your_name
# 管理员密码(可选,默认 CF1234@!)
CFRAME_ADMIN_PASSWORD=input_your_password

# 站点信息(均可选)
NUXT_PUBLIC_APP_TITLE=
NUXT_PUBLIC_APP_SLOGAN=
NUXT_PUBLIC_APP_AUTHOR=
NUXT_PUBLIC_APP_AVATAR_URL=

# 地图提供器 (maplibre/mapbox)
NUXT_PUBLIC_MAP_PROVIDER=maplibre
# 使用 MapLibre 需要 MapTiler 访问令牌
NUXT_PUBLIC_MAP_MAPLIBRE_TOKEN=
# 使用 Mapbox 需要 Mapbox 访问令牌
NUXT_PUBLIC_MAPBOX_ACCESS_TOKEN=

# Mapbox 无域名限制令牌(反向地理编码,可选)
NUXT_MAPBOX_ACCESS_TOKEN=

# 存储提供者(local、s3 或 openlist)
NUXT_STORAGE_PROVIDER=local
NUXT_PROVIDER_LOCAL_PATH=/app/data/storage

# 会话密码(必须,32 位随机字符串)
NUXT_SESSION_PASSWORD=Q7RZjHCLGiZhUj24hJYUCW1AlCJ6NngQ

其中,务必修改 NUXT_SESSION_PASSWORD 中的随机字符串。CFRAME_ADMIN_EMAILCFRAME_ADMIN_NAMECFRAME_ADMIN_PASSWORD 如果不填就是默认值,CFRAME_ADMIN_EMAIL 如果填写,需要确保是邮箱格式

如果你想将图片上传到 VPS,则可以保持 NUXT_STORAGE_PROVIDER 的配置,这样最简单,如果你想使用 s3,则可以访问官网文档自行研究(我没用过 s3)。

站点信息 等都是选填,按需填写即可。

  1. 配置好文件后,通过 ssh 连接服务器,并且 cd /path/to/chronofram_folder 进入到 docker-compose.yml 所在的文件夹下,然后运行 docker-compose up -d 即可运行该项目。 如果想要停止该项目,同样是进入到相同目录下,运行 docker-compose down 即可。 另外,项目所有的数据都会保存到 /path/to/chronofram_folder 目录下的 data 文件夹中。
  1. 部署好服务后,你就可以通过 http://ip:3000进行访问了,最好是用一个二级域名反向代理一下,便于访问,也更加安全。

上传 Live Photo

部署好服务后,只需输入账户密码即可进入后台上传照片。但是 Live photo 上传需要特别注意一下。

  1. 需要将 iPhone 通过 AirDrop 传到 Mac 上(其他方式也行)
  2. 选中 Live Photo 照片,点击左下角 分享 按钮,在顶部 选项 中,勾选最底部的 所有照片数据,这样 Mac 上接收到的就是一个文件夹,文件夹中包含一张图片和一个同名的 .mov文件。
  3. 进入 chronoframe 后台,上传照片时,需要同时上传这两个文件,这样就能在画廊看到 Live Photo 了

最后

教程非常简单,其余的功能就留给大家自行探索啦~

Docker 部署一个精美的画廊 —— ChronoFrame最先出现在Jack's Space

用一台被淘汰的 Mac mini 来做软路由

2024年1月24日 17:35

其实在之前的文章里就提到过,我一直是一个垃圾佬节俭主义者,所有的东西我会本着能用就用的原则让它发挥余热。最近手上拿到了一台已经退役的 Mac mini,它有多老呢?

截屏2024-01-16 18.25.34.png

这是一台 2014 Late 版本的 mini,双核四线程的 i5 处理器,8G 的内存,硬盘已经被升级成了 256G 的 SATA SSD。这样一台主机,现在到小黄鱼上几百块就能拿下,可能很多人的软路由都比它的纸面性能要好。而我在它发布十年后的今天,依然选择其作为软路由却是看中了它的几个独特优势。

Mac mini 的优势

对我来说,其最大最核心的优势就是 macOS,是的你没有看错,很多人拿到这类过时的苹果电脑,第一时间就是格式化掉硬盘,然后装一个 ubuntu 之类的 Linux。但如果你要装 Linux 真的没必要用苹果电脑,还要解决一堆兼容性问题。反之如果我继续沿用 macOS 则可以享受其完善的软件生态还有原生的硬件兼容性。当然,最重要的一点,我是 Surge 的用户,这样软路由的软件就不用再去重复投资了。

颜值,即使已经到 2024 年了,你要找一台能摆上台面 PC 主机依然还是很难,这代 Mac mini 的设计已经沿用了至少十年,看上去依然优雅毫不过时。我打算把它放在客厅的电视柜,这么一个每天都能看到的地方可不能放一个看着糟心的玩意。我个人比较欣赏的是它把电源集成进了机箱,不像现在很多迷你主机虽然看着很小,但还得配一个巨大的外置电源。

macOS 配置

首先我们要为这台机器装上最新的 macOS 操作系统,通过这个网址查询得知,这台 2014 年末的 Mac mini 最高只能装到 macOS 12 Monterey。选择尽量新的操作系统可以让系统更好地获得安全补丁,因此我把之前的系统抹掉,重新安装了 macOS 12。

截屏2024-01-24 15.58.48.png

为了让这台 Mac mini 胜任软路由的角色,我们还需要对其系统进行一些配置。路由器是全年 24 小时常开的服务,所以稳定性是第一要务,同时我们还要考虑一些特殊情况,比如停电后的恢复。

截屏2024-01-24 16.02.34.png

所以我们进入“设置/节能”,第一个钩一定要打上,要不然一会儿系统会自动进入睡眠模式,软路由就噶了。如果有“如果可能,使硬盘进入睡眠”这个选项(比如你外接了硬盘),把这个钩可以点掉,不需要节约这一点电量,反而有时候硬盘休眠再唤醒会有 Bug。“断电后自动启动”这个选项也要打开,这可以让你的机器在停电后再来电时自动开机,从而实现网络的自动恢复。要不然家里没人,就只能干瞪眼了。

截屏2024-01-24 16.12.46.png

再进入“用户与群组/登录选项”,把自动登录打开,选择当前的用户登录即可。Surge 运行在用户态,如果没有登录是无法启动的,所以我们要保证这台机器只要一开机就能自动进入系统,然后启动相应的服务。

截屏2024-01-24 16.17.24.png

在“软件更新”的设置里,把所有自动检查和自动更新的钩都去掉,你不希望网上着好好的,软路由自己重启去更新系统了吧。

其它设置:

  1. 建议把 WIFI 网络禁用掉,我们只需要用到有线网卡。把蓝牙也禁用掉。
  2. 建议把声音静音,Mac mini 是有自带扬声器的,你的某些操作可能会触发音效,你应该不希望自己的路由器发出莫名其妙的声音吧。
  3. 系统偏好设置 / 辅助功能 / 显示:勾选 ✅减弱动态效果
  4. 系统偏好设置 / 通用:取消 ☑️允许基于墙纸调整窗口色调

软路由/Surge 配置

在配置软路由之前,我们面临一个选择,是让这台路由成为主路由还是旁路由。我的考虑是前者虽然节省了一台设备,但系统的容错性比较低,软路由虽然我们做到尽量稳定,但它的宕机概率还是高过硬路由。如果你把它作为主路由,届时很可能会面对整体无法上网的情况。而作为旁路由就要灵活得多,这台 Mac mini 你可以随时拿掉去升级/修理,我只需要把子网络的出口网关地址改一下就行了。

Untitled-2022-06-08-1159.png

上图就是一个简单的家庭网络拓扑示意图,可以看到我们分了两个网络(黄色和绿色),一般的家庭联网设备都运行在绿色的网络(192.168.10.0/24)中。它们只与一个主路由连接(一般是一个 WIFI 路由),与上层设备之间是隔离的,不需要关心自己的流量经过了哪些设备,连上就可以用。

而对于黄色网络(192.168.1.0/24),WIFI 路由的网关设置到软路由(192.168.1.10)上就可以通过软路由上网,如果软路由出故障了,就可以把网关设置到主网关(192.168.1.1)继续使用,上层设备可以做到无感知切换。这种组网的方式比较简单可靠,也兼顾了灵活性,是一种不折腾的选择。

截屏2024-01-24 16.36.04.png

而如果你跟我一样选择 Surge,那么软路由的设置就相当简单了,只需要打开它的增强模式即可。注意两点:

  1. 不要打开 DHCP 模式,因为你只是个旁路由,DHCP 服务已经由主路由承担了,你只需要承担网络流量即可。
  2. 记得给 Mac mini 设置一个固定的 IP 地址,而且要避开主路由的 DHCP 自动分配的址段。

截屏2024-01-24 17.13.44.png

接下来就是给绿色网络的 WIFI 路由器设置出口网关了:

  1. 把它的出口(WAN)配置为固定 IP(Static IP)模式,网关地址(Gateway)设置为这台软路由的地址。
  2. 还有一个比较特殊的,你要把它的 DNS 设置为如上图所示 Effective DNS 的地址 198.18.0.2

至此,所有的软路由配置已经完成了,如果不出意外的话所有的上层网络流量都会经由这台 Mac mini 再联向互联网。而 Surge 本身还有不少玩法,比如你可以把 Surge Ponte 打开,这样如果你在多台设备上运行了 Surge,它们之间就可以组成一个加密的虚拟网络,我一般来连 VNC 查看这台 Mac mini 的运行状态。

后话

WechatIMG1278.jpg

一图胜千言,垃圾佬的快乐谁能懂😂

一次惊心动魄的数据拯救之旅

2023年12月24日 00:20

作为一个苦逼的创业公司,我们对开发服务器硬件的态度是能省就省。于是我们利用各自垃圾闲置硬件攒出了一个堪用的集群,来供公司平时开发使用。

IMG_8779.jpg

可以看出这分属不同厂商,不同年代,不同配置的服务器及网络设备体现了深深的历史厚重感。里面最耀眼当属 4 台银光闪闪的 Mac mini,把它们加进来还是我的主意,毕竟跑跑持续集成又不是不能用。但此次事件的主角不是它们,而是下方那台贴了黄色符咒的 ThinkStation。符咒是@明城贴的,这台机器也是他攒的,作为一个资深垃圾佬当然是用不起不会用 ThinkStation 这么高端的货色,所以如你所见它只是个机箱,里面早就面目全非了。不过为了方便起见我后面都称其为 ThinkStation 了。

流年不利

周四那天首先有同事报告公司 Gitlab 打不开了,作为我们内部开发使用的主要服务,它一直作为 KVM 虚拟机跑在这台 ThinkStation 上,于是我立马 ssh 登陆上母机去。一顿猛操作发现虚拟机控制台已经进不去了,用虚拟机重启命令也毫无反应,看起来是彻底死机了。这个时候我们只好祭出大杀器,重启服务器,于是轻车熟路敲下 poweroff 然后准备等它关闭,可奇怪的是我都喝完一杯茶了这服务器还丝毫没有关闭的迹象,看起来母机也死掉了。这个时候只有最后一招,关闭电源。

长按电源键关机之前,我看了一眼符咒,心想这次只能靠你了。按住电源键几秒后,听见继电器咔哒一声脆响,然后所有闪烁的灯光都熄灭了。深吸一口气,再次按下电源键,耳边响起硬盘电机开始加速的声音,回到位置上准备如往常一样再次 ssh 连接上去。但这次等待的时间有点长,十分钟以后我依然无法连接。不仅如此,连 ping 都没法到达这台服务器。

情况有些不对,我再次重复了几次物理开关机的过程,服务器依然如一潭死水无法连接。事情大条了!于是我赶紧联系明城让他看看这台一手打造的服务器到底出了什么问题。于是再经过他的一顿猛烈操作,告诉我一个噩耗,硬盘 RAID 挂了,数据损坏导致母机内核都崩溃了😱。

WechatIMG1004.jpg

这是两块 2T 的磁盘做了 RAID 1,母机用的是 ZFS 的文件系统,按道理说安全性已经拉满了,怎么会一声不响都挂掉了?不过现在首要任务是把里面的数据弄出来,毕竟现在没地方提交代码了。虽然我们做了数据备份,但备份是按天来计的,如果能拿到实时的数据是最好不过了。

拯救数据

IMG_9706.jpg

于是拆下其中的一块硬盘,插到我自己的黑苹果上。也幸亏我在公司用的是黑苹果,还有地方给我插这块硬盘,但马上我遇到了第一个问题,怎么在 MacOS 下挂载一个 ZFS 的磁盘。幸好我发现了 OpenZFS, 而且已经有开发者做了 MacOS 的实现 OpenZFS on OSX,用 brew 安装起来也非常简单丝滑:

brew install openzfs

因为涉及到扩展安装,所以中途会跳出安全警告,记得要到 “安全性与隐私” 处忽略这个警告继续安装。安装好以后你还需要手动挂载这块硬盘,ZFS 跟一般的文件系统还有点不同,它有个 pool 池的概念,你要先使用命令

sudo zpool import [poolname]

来导入 ZFS 池,如果你不知道 pool 的名字,可以运行 sudo zpool import 来查看所有待导入的 pool。导入以后按说还有个 sudo zfs mount 的动作来挂载,不过在 MacOS 上已经自动挂载好了。

WechatIMG1024.jpg

这三个最重要的 KVM 磁盘映像文件就映入眼帘了,分别对应了三台运行在这台服务器上的虚拟机,而 Gitlab 就在这台 wuhan(武汉)上,大小有 128G 左右,最理想的情况是我直接把它拷贝出来就行了。然而

WechatIMG1031.jpg

我试了三四次,每次都在拷贝到 116G 左右的时候就遇到 IO 错误,看来问题就出在这里了。根据我的猜测这个文件应该不是完全损坏,坏掉的部分可能只是一小块,如果它对应的不是系统核心文件(这个概率很大,毕竟 128G 的文件核心只占一小部分),那这个映像完全还能使用。因此我需要一个更底层的拷贝工具来跳过错误部分,它就是 dd

dd if=source_file of=target_file bs=1M conv=noerror,sync

这里 bs (block size) 我设置了 1M,也就是每个区块大小为 1M,遇到错误就跳过它。执行这个命令后,果然在读取到 116G 左右的时候报了两个错误,跳过之后顺利拷贝完成了。现在我在自己的 MacOS 上得到一个未知的 KVM 磁盘映像,其实工作按理说已经告一段落了。但我看了自己性能过剩的黑苹果主机,冒出一个想法,能不能让它暂时运行在我的电脑上,起码先让大家恢复代码提交。

在 Mac 上运行 QEMU

qcow2 是常用的虚拟机镜像,在 MacOS 上我们可以用 QEMU 来运行它,通常情况下我们可以直接用 brew install qemu 来安装它。但现在有个更好的选择:UTM,这是一个 MacOS 上开源的 QEMU 的图形界面实现。我选择它的原因是图形界面能帮助我省掉不少时间,毕竟 QEMU 的参数非常复杂,如果有人帮你做好了适配可以省掉不少时间。

但是 UTM 现在有个问题,它不能直接用一个 qcow2 文件来作为磁盘启动,创建虚拟机的时候必须选择一个启动镜像,并且新建一块虚拟磁盘。为了解决这个问题,你可以在创建虚拟机的时候随便选一个文件作为启动光盘,创建完以后编辑虚拟机,把这块镜像删掉即可。

现在还需要解决如何让它使用我这块已经拷贝好的 qcow2 文件作为硬盘,千万不要使用它的导入磁盘映像功能,因为这只会让它把镜像拷贝过去,然后转换成自己的格式。经过我的摸索可以这样做,首先把当前磁盘映像的 Interface 修改成 virtio,因为这也是 KVM 创建映像时的类型,既然我们要直接读取它就必须保持一致。改完后记得点保存,因为后面要直接操作文件。

截屏2023-12-23 18.46.35.png

然后我们要把这块硬盘替换成拷贝好的 qcow2 文件,首先找到 UTM 为这个虚拟机自动创建好的磁盘映像,它位于目录

~/Library/Containers/com.utmapp.UTM/Data/Documents/{你的虚拟机名称}.utm/Images/

这里有个已经存在的 qcow2 文件,给它重命名一下,然后用 ln -s 命令把原来系统的 qcow2 文件软链接过来替换已经存在的文件,这一步的目的就是“骗” UTM,让它以为硬盘是自己那块,其实已经被我们给换掉了。

然后还得对网络做一番修改,我专门接了一块 USB 网卡来做桥接,所以把这里的网络模式改为“桥接”,选择对应的网卡,然后 "Emulated Network Card" 这一项选择 "virtio-net-pci",这也是为了和 KVM 的模式兼容。

截屏2023-12-23 18.46.58.png

选择好以后保存,咱们就可以大喊原神启动。

截屏2023-12-22 21.02.19.png

牛X!启动成功,可以看到操作系统已经检测到了文件错误,尝试修复成功之后终于进入到了熟悉的登录界面。正常进入系统之后就是一些常规的操作了,总之经过一番折腾我把运行在服务器上的 Gitlab 成功迁移至本地运行,性能居然还不错。

1393545561.png

结论

  1. 任何系统都有可能出故障,重要的数据最好做异地多活。这次是硬件故障,万一哪天硬盘直接被拿走就废了。
  2. 磁盘故障不一定代表文件不可用,越大的文件其中不重要的部分占比就越大,因此还是有很大几率抢救成功的。
  3. 越开放的技术,工具和资料也越多,其实健壮性也越好。比如 KVM 用的 qcow2,我拷贝到 MacOS 上照样可以通过 QEMU 启动它。
  4. 符咒没起作用,封建迷信害死人🤦

现在做个人博客的最低成本是多少

2024年10月7日 22:44

距离上次在知乎回答这个问题已经过去一年多了,我决定重新修正一下这个答案。

先说我的成本,243.12元/年。

直接上清单:

如果不需要评论功能,这个成本可能会更低,但考虑到后期需要备案,还得有服务器,所以服务器的成本总也绕不过。

服务器我选择的是阿里云新人优惠,2022年初阿里云新人优惠,3年196元,我直接续费到了2028年。(这种大力度优惠不会再有了。)

最初博客静态文件和图床都使用了阿里云oss,不幸的是,随着博客流量越来越大,图床的存储桶从最初的一年十几块钱,到后来一天一两块钱,看得我很焦虑。用客户端备份了整个存储桶里的图片,居然有1.3G!

直到将图床整体迁移到cloudflare,配置好了uPic,虽然慢点,但也还能接受。至少不用再为每天那几块钱心惊肉跳了。

xyz溢价域名,一年6块8,10年也才68块钱,选那种纯数字域名即可。xyz域名支持在国内备案,虽然图床域名解析在了cloudflare,但博客静态文件还在阿里云,担心批量使用境外链接会被污染,就先在阿里云备案后,再解析到cloudflare。

cloudflare真是活菩萨,发现解析到cloudflare的域名可以直接带上SSL签名,还可以设置反代,省去了搭建面板设置反代的麻烦。

没准哪天心血来潮,把博客整个数据迁移到cloudflare,直接零成本,也不是不可能。但念到总有一天cloudflare可能会被墙,先这样吧。

Miniflux + AI,无痛阅读英文RSS订阅源

2024年12月29日 20:07

前言

Miniflux + AI,无痛阅读英文RSS订阅源

最近一直想为自己的RSS订阅加上翻译的功能,降低自己阅读国外Newsletter和RSS信息的难度。现在手上用的RSS阅读客户端都不具备这个功能,只能从服务端入手了。

我使用Miniflux来管理订阅,它具备完整的API接口,可以充分根据自己需求DIY插件。当然作为一个代码弱手,我还是选择现成的工具。Miniflux-ai是国人开发、官方认可的第三方插件,可以为Miniflux增加以下AI功能:

  • 文章摘要
  • 文章翻译
  • 每日新闻简报(基于已订阅的内容)
Miniflux + AI,无痛阅读英文RSS订阅源
Lire for mac

我接入的大模型是Gemini-Pro,实际使用下来,摘要和翻译功能都很好用,也可以根据自己实际的需求修改相关的prompt优化翻译和摘要结果。总之,确实满足了我的翻译需求。

💡
如果你使用Lire作为阅读器,默认的配置可能会导致文章列表出现乱码,可以通过调整配置解决这个问题(后附)。

不过新闻简报功能就差强人意了,会出现主语模糊的情况。不过毕竟不是我的主要需求,权当看个乐子吧~

附:Miniflux-ai的安装和配置

Miniflux-ai官方的安装和使用教程比较简陋(当然实际部署也很简单),主要的安装步骤请查阅官方文档
可能产生疑问的地方主要在Miniflux-ai的配置文件config.yml上。我这里基于官方的配置文件模板作一些填写注释:

# INFO、DEBUG、WARN、ERROR
log_level: "INFO"

miniflux:
# 主程序的访问网址,请与docker-compose.yml中的BASE_URL保持一致
  base_url: https://your.server.com
# 进入Miniflux主程序后台→设置→API密钥,点击创建新的密钥即可获得
  api_key: Miniflux_API_key_here
# 进入Miniflux主程序后台→设置→集成,找到页面最底部的Webhook部分,勾选「启用Webhook」,在Webhook URL中填入「http://miniflux_ai/api/miniflux-ai」,点击更新,即可获得Webhook密钥
  webhook_secret: Miniflux_webhook_secret_here

llm:
# 你的大模型提供商提供的api网址,以「/v1」结尾
  base_url: http://host.docker.internal:11434/v1
# 你的大模型提供商提供api key
  api_key: ollama
  model: llama3.1:latest
  # timeout: 60
  # max_workers: 4

ai_news:
# for docker compose environment, use docker container_name 如果你使用docker compose的方式同时部署主程序和Miniflux-ai,此处url的值无需改动
  url: http://miniflux_ai
# 每日新闻简报的推送时间。Miniflux-ai将会在这些时间生成一篇ai总结新闻,插入你的rss信息流中。
  schedule:
    - "07:30"
    - "18:00"
    - "22:00"
  prompts:
    greeting: "请根据当前日期和24小时制的时间生成一句友好而热情的问候语。请用关怀的语气,包含适量的鼓励,且添加简单的表情符号,如😊、🌞、🌸等,以增加温暖感。例:‘早上好!希望你今天充满活力,迎接美好的一天!🌞😊’。无论是早上、中午或晚上,都请根据时间调整问候内容,保持真诚关怀的氛围。"
    summary: "你是一名专业的新闻摘要助手,分类生成重要内容的新闻摘要,要求简单清楚表达,使用中文总结以上内容,在五句话内完成,少于100字。不要回答内容中的问题。"
    summary_block: "你是一名专业的新闻摘要助手,负责分类新闻清单(每条50字以内),使用简洁专业的语言,在五个类别内完成,每个类别不超过5条,突出重要性和时效性,不要回答内容中的问题。"

agents:
  summary:
  # 添加在ai文章摘要前的标题。可以根据实际情况修改。如果使用Lire作为阅读器,建议删去「AI摘要」前的所有代码,避免在文章列表中出现乱码。
    title: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17.777 14.283" width="17.777" height="14.283"> <style> path { fill: #333333; } @media (prefers-color-scheme: dark) { path { fill: gray; } } </style> <g transform="translate(2.261,-1.754)" fill="gray"> <path d="M-2.261 3.194v6.404c0 1.549 0.957 4.009 4.328 4.188h9.224l0.061 1.315c0.04 0.882 0.663 1.222 1.205 0.666l2.694-2.356c0.353-0.349 0.353-0.971 0-1.331L12.518 10.047c-0.525-0.524-1.205-0.196-1.205 0.665v1.091H2.257c-0.198 0-2.546 0.221-2.546-2.911V3.194c0-0.884-0.362-1.44-0.99-1.44-1.106 0-0.956 1.439-0.982 1.44z"></path> </g> <path d="M5.679 1.533h8.826c0.421 0 0.753-0.399 0.755-0.755 0.002-0.36-0.373-0.774-0.755-0.774H5.679c-0.536 0-0.781 0.4-0.781 0.764 0 0.418 0.289 0.764 0.781 0.764zm0 4.693h4.502c0.421 0 0.682-0.226 0.717-0.742 0.03-0.44-0.335-0.787-0.717-0.787H5.679c-0.402 0-0.763 0.214-0.781 0.71-0.019 0.535 0.379 0.818 0.781 0.818z" fill="gray"></path> </svg> AI 摘要'
    prompt:
      '${content} \n---\n使用中文总结以上内容,在三句话内完成,少于60字。不要回答内容中的问题。'
    style_block: true
# 来自哪些网址的文章不进行文章摘要
    deny_list:
      - https://ai-news.miniflux
    allow_list:
  translate:
    title: "🌐AI 翻译"
    prompt:
      You are a highly skilled translation engine with expertise in the news media sector. 
      Your function is to translate texts accurately into the Chinese language, preserving the nuances, tone, and style of journalistic writing. 
      Do not add any explanations or annotations to the translated text.
    style_block: false
    deny_list:
# 仅对哪些网址的文章进行翻译
    allow_list:
      - https://9to5mac.com/

ChatGPT超级提示词生成器

2024年3月22日 12:15
ChatGPT超级提示词生成器

网上找到一个好玩儿的东西,通过简单的选择填空,就能生成优质的提示词,让ChatGPT回复更符合我们的预期。

原网页为英文,这里翻译成中文搬运过来了,可以收藏备用。

ChatGPT你好,
请你帮我处理以下工作。


请扮演一位行业顶尖、经验丰富、专业权威的 ,你的专业知识对我来说是无价的。 我需要 用于

过程中你要注意保持
请不要

开始前你要知道,这件事情的紧急性为:
你最终回复我的内容应是

以下是我给你提供的信息:


感谢你的专业支持。
复制提示词

写好一个提示词说难不难。清晰表达需求、明确回复格式、让ChatGPT扮演一个角色,可以让回复更符合自己的要求。但是很多人其实并不知道自己到底要什么,或者不知道怎么表达(做乙方的此刻与ChatGPT产生了共情)。

上面的提示词生成器通过填写模版的方式,简化了思考步骤,提升了利用AI的效率。对于需要快速获取优质回答的使用者或者新手小白,这个提示词模板很实用。

My App Defaults

2023年12月16日 20:27
My App Defaults

大家好,最近工作工作真是忙得不可开交,一天24小时不够用的,也因此好久没机会更新了。
看到很多博主在分享自己的主力软件列表,今天也来分享一下我的,权当水一下更新嘿嘿😜

My App Defaults

📨 邮箱客户端:Canary
📮 邮箱服务器:Gmail(个人)、iCloud(绑定独立域名)
📝 笔记:Memos
✅ To-Do:NotePlan
📷 手机摄影:原生相机
🟦 照片管理:原生相册
📆 日历:原生日历
📁 云盘:Onedrive(主力)+iCloud(照片)
📖 RSS:Miniflux(服务端)+Reeder+Fluent
🙍🏻‍♂️ 联系人:🈚
🌐 浏览器:Chrome
💬 聊天:微信😅
🔖 书签:Linkding(Barely use)
📑 稍后读:Linkding+Miniflux
📜 文字处理:MWeb
📈 表格:MS 365
📊 演示文稿:MS 365
🛒 购物清单:🈚
🍴 饮食规划:🈚
💰 记账:🈚
📰 新闻:同Rss
🎵 音乐:Apple Music
🎤 播客:小宇宙
🔐 密码管理:Chrome密码管理器

🚀软件启动器:Alfred
📃博客:Ghost
🔍翻译:有道翻译Alfred插件+沉浸式翻译
🎬追剧记录:TV Time
🤖人工智能对话:ChatGPT Next Web
🖌️平面设计:稿定设计+PS+Pixelmator

有话要说

  1. 主力笔记App用什么?从印象笔记没落到现在貌似就没有找到过合心意的。要么收费高,要么不支持多平台,要么有那么一两个难以忍受的缺点。老实说,现在我临时记笔记最多的地方,其实是微信文件传输助手。
  2. Rss是我获取新闻的主要方式,用Werss订阅了好几个公众号,还有活菩萨网友提供的华尔街日报源,感恩拜拜🙏
  3. 在密码管理这块,一开始我用了Enpass超过两年时间,一百元以内的买断价格加上全平台支持,在当时很得我心。后来chrome浏览器开始在密码管理的功能上发力,在iOS上开始逐渐接入原生密码管理接口,在各个app上都能填充密码,现在已经完全取代掉Enpass,成为使用起来最没门槛最方便的密码管理软件了。
  4. 不会设计的文案不是一个好活动策划。无论是工作还是平时更新博客,平面设计都占了显著的一部分工作量,稿定设计真的是我离不开的一个设计网站,可以很快套版输出质量很高的图片。

结语

这篇文章或多或少也算是今年生活的一个小小小小总结,要真的写出来一篇年度总结,我想我要请好两周假才能把这完全超出我想象的一年整理成文字吧,这样看来或许农历春节才是一个比较适合我来总结一整年的时机。大家今年过的怎么样呢?

QuitAll:我的人生态度,老Macbook的回春之路

2023年11月28日 21:41
QuitAll:我的人生态度,老Macbook的回春之路

我的2018款MacBook Pro在服役五年后,已经开始显现出各种老态:电池健康度低、使用速度随开机时间降低等。
其中电池健康度没办法,想解决只有换电池,不过目前74%的健康度还能高强度使用四五十分钟,我还能忍。
但是使用速度的降低是实在有点难以忍受。连续开机2-3天,机器的反应速度就开始出现明显卡顿,甚至影响拖动等操作,只有重启才能解决。

重启电脑真的是很烦的一件事情,在重启过程中有各种使用到一半的文档跳出来叫你复查保存,如果使用了微信双开,重启后还得重复一次繁杂的双开过程。那是否有能实现重启的效果,但又不需要真实的退出所有应用的方法呢?
在我查看最新上架的SetApp软件时,就让我发现了这样一个宝藏软件,不仅完美解决了电脑卡顿的问题,还顺带给了我很多其他惊喜。

老MacBook救星:QuitAll

QuitAll:我的人生态度,老Macbook的回春之路

QuitAll是一款简洁小巧的软件,它蛰伏在状态栏,你只需要简单点击一下他的「QuitAll」按钮,就能快速退出当前正在运行的程序。

QuitAll:我的人生态度,老Macbook的回春之路

你还可以单独设置某些进程总是不退出、总是退出,这样就能在为电脑运行腾出更多空间的同时,保留重要的程序运行。

可以说QuitAll是重启电脑的最佳替代品,可以让你毫无负担的完成一次无痛重启。

在小巧的程序界面底部,还会随机跳出各种退堂鼓名言:

Don't give up, quit instead ✔️
There is no failing in quitting 🏅

这些小趣味也消除了一些卡顿带来的烦躁。

重启能解决的玄学问题,它也能解决!

QuitAll能帮Mac恢复运行速度,已经让我很惊喜了。但没想到它还能把很多我们只能归类为「玄学」、「宿命」、「报应」的疑难电脑问题一并解决。

1.解决快捷指令「未能与帮助程序通信」的问题

QuitAll:我的人生态度,老Macbook的回春之路

我的网站CDN和图片都使用了Bunny.net的服务。在写完文章后,我会将Markdown的全文内容复制扔到Bunny图片批量上传捷径里,让捷径帮我把文章里的图片自动挑出来上传,并自动替换为网络图片地址。

但是快捷指令经常会出现「未能与帮助程序通信」的问题,之前只有重启可以解决得了。但是有了QuitAll之后我惊喜的发现,使用它来一键退出大部分程序也能解决问题!

相信是与快捷指令有关的某些程序出现了错误,只有重新启动才能解决。但是我实在找不到是哪个程序需要解决,QuitAll就帮上了大忙。

2.解决OneDrive Mac 客户端频繁自动

QuitAll:我的人生态度,老Macbook的回春之路

囿于MacBook可怜兮兮的256G储存空间,我的大部分工作文件都会同步到OneDrive上,并且会自己动删除本地文件以节省空间。在使用到这些文件时,OneDrive客户端会再从云端把文件下载下来。

这一套逻辑其实是很丝滑的,OneDrive的下载速度在挂梯后并不慢。但是OneDrive的客户端总是会自己默默退出程序,没有任何提示,导致我经常性要手动启动OneDrive,等待它缓慢的登录、同步流程,才能下载文件下来。

但在使用QuitAll后我发现,这个问题也消失了!只要用QuitAll退出所有程序一次,OneDrive就能长久的挂在电脑上。

下载安装

QuitAll是收费软件,不过费用不算贵,一次付费$10就能终身使用;或者如果在用Setapp套餐,也可以直接免费用。

QuitAll:我的人生态度,老Macbook的回春之路

QuiAll for Mac

不必重启,一键回春

前往官网下载

不过我在想,会不会腾讯柠檬清理、CleanMyMac之类的也能达到同样效果呢?

Google Play 应用上架二三事

2025年7月13日 21:05

首次上架应用到google play还是2015年的时候,那个时候上架应用限制比较少,注册个账号信用卡付个款就行了。自己之前有个账号,但是闲置许久,加上自己的一些骚操作账号被Google给永久禁用了,并且还给我发了个邮件告知不要再尝试注册新的账号了。最近自己的新应用想要上架,于是又重新注册了个个人账号。同时公司的产品也要上架Google play,前前后后经过了小半年的折腾才终于搞定,将应用上架,这里就来说道说道。

账号注册

早期的时候,无论是个人账号还是公司账号注册都不需要实名验证的,因此只需要填一下联系信息使用信用卡付款25美元就可以直接注册成功了。但是从2023年开始,无论是已有账号还是新注册的账号都需要进行验证。对于个人账号,只需要填写一份付款资料,并且验证身份,身份证,护照信息都可以,这个信息需要与付款资料中所填写的需要一样,并不要求付款信用卡的账单地址和姓名与这个相同。

对于公司账号的注册,则是和苹果一样要求提供邓白氏码(D-U-N-S Number),输入邓白氏码之后会自动获取到公司的名称地址信息生成付款资料。和个人账号一样,付款的信用卡也没啥要求。在验证的时候,则是需要提供公司的政府签发的文件,比如国内的营业执照,具体可以查看Google play 官方文档。公司的验证也还需要验证一位开发发者的信息,这里的要求跟个人差不多,身份证,护照等都可以,而且不需要提供在这家公司任职的任何证明文件,公司注册地和个人不在同一个国家也没关系(这一点,苹果的开发者注册是要求提供个人在公司任职的证明文件或者名片之类的东西的)。

如果所有的这些证明文件都能够顺利的提供,并且邮箱验证和电话验证都没问题,那么账号注册是很容易的。最后有一点需要补充,付款的信用卡是不能使用银联卡的,需要visa或者mastercard。总体来说,比苹果开发者账号要容易,就上面说的我的个人账号被封之后,使用家人的信息又重新顺利注册了一个新账号。而公司的账号,在获取法务同时完成公司DUNS Number的申请之后,也都顺利的完成注册了。但这些完成之后,也还是只是完成了万里长征的第一步。

个人应用上架

以前个人应用在google play上架是很容易的,而在2023年11月,google 出了一个新政策,对于在23年11月13日之后注册的新账号,发布应用时必须满足特定的测试要求才能正式发布。具体要求是,正式发布之前,需要在google play上进行封闭测试,需要至少12名测试人员测试至少14天持续参与测试。这个对于个人开发者来说,还是不太容易达成的,这至少12名测试人员,是需要开发者将他们的Google 邮箱输入到google play开发后台,他们接受并且根据开发者提供的链接进入下载安装的。至于连续14天的持续参与测试,这个不太清楚Google 是如何统计的,封闭时间肯定是要保证14天以上,但是如何保证每天都至少有12人参与这个不确定是否强制要求。

我的应用在开发完成之后,通过在小众软件和Linux.do社区征集到了一定的热心网友参与了测试,从而顺利完成了封闭测试,对于工具类的软件这是个不错的方式。对于这一点,虽然加大了个人上架应用的难度,但是我想也是可以提高上架应用质量,毕竟对于个人开发开发者来说,测试相比于公司开发的应用来说会更加薄弱。

公司应用上架

公司开发者应用的上架,没有上面对于个人开发者的限制,但是我们在上架的时候遇到了更多的问题。因为我们所在的是金融行业,在上架的时候会更加的敏感,因此也更需要小心一点。

我们在第一次上架的时候,填完了所有的信息,上传了应用,提交审核之后,谷歌以我们需要登录为由审核不通过。提交了登录需要的信息之后,等了超过十天,结果直接账号被禁用了,原因是高风险,发邮件和申述都没用,并且不告知具体的原因。搞得我们很受伤,不知道该怎么办。商量之后决定让公司注册新的实体再重新注册开发者账号。

另一方面,想到公司以前的应用可能还有老的开发者账号,或许可以用,找回了老账号的邮箱,使用新的公司主体信息进行了验证,之后顺利的提交了应用。这一次为了稳妥起见,我们先提交了封闭测试和开发测试,都通过了之后才提交的正式发布,所有信息都填写准确,google 也很快的通过了审核。

一些经验

虽然说上架Google play越来越严格,但是相比与国内来说还是容易很多的,国内上架对于个人开发者极度的不友好,并且备案,软著,哪个都不是好搞的。

上架Google play我认为第一条原则就是诚实,填写资料要真实,不仅仅是开发者信息如此,同时应用使用到的权限,收集的用户资料,等等都要如实填写,不可挂羊头卖狗肉。也要避免给审核人员看到的只有某一个功能,实际应用内有很多的功能会在审核后对用户开放。如果应用需要登录,最后要提供账号密码给google 的审核人员,对于非账号登录的,如加密货币钱包应用可以提供助记词或者操作指导的视频等。

另外,上架的应用应用做到尽量少的用户信息收集和权限获取。比如获取用户位置,如果不是主要功能,尽量不要获取。对于一些权限,如读写相册,相机权限等,在新版本系统中有提供不使用这些权限,直接通过系统的API实现的方式,也最好不要请求这些权限。对于DeviceId现在已经不允许收集了,对于Phone_State, 广告Id等,也应该尽量做到不要收集。

谷歌现在每年都要求应用升级Target 版本,这一点开发者也是需要去乖乖的做的,否者新应用无法上架,老应用无法更新。除此之外,对于Android的新特性开发者也应用去积极适配,对于一些特性,google play console也会提醒开发者去适配,比如下图所示。

对于上架的应用,即使没有发布新版本,也有可能被抽查去审核,这时候如果遇到了问题,谷歌也会发信息来让你整改,因此需要关注后台和邮箱,遇到问题要在最终截止日之前修掉对应问题,否者真的会被下架。我就遇到了这个问题,我所提供的登录凭据,审核人员自己输入错误,把我填写的o输入成了0,导致他无法登录,就给我发了整改通知。不过我在修复之后,错误提醒过了仍然没有消失,对于这一点,如果你已经确保改过了就不用再担心了。以下是错误提醒,过了这么多天仍然还在显示。

老账号的价值很高,审核也会比新账号更容易过,因此如果你有一个老账号,请保管好。

对于权限和隐私方面的检查,可以使用Google做的一款工具Checks,它可以帮助检查app中使用到的权限,请求的网络,同时还能够审查隐私政策文件,在发布前借助这个工具检查可以很大程度减少应用审核被拒的风险。

最后

从事Android开发10余年,大多数时间也都是做的海外应用,也是经历过了很多次google play被拒,审核的政策一直在变,之前能通过不代表现在也能通过审核,因此需要不断的学习google play的政策文件。

最后的最后,宣传一下我开发的Memos客户端应用fymemos,欢迎到google play下载。也欢迎留言交流应用上架的故事。

值得反复学习的google 文档:

  1. Play Academy
  2. Play 开发者政策中心
  3. Play Console Help Center

看完评论一下吧

Chromebook折腾之2025

2025年3月5日 14:40

最近淘了一台洋垃圾Chromebook,折腾了一段时间,目前已经基本在日常使用了。在折腾过程中查找了许多的网上资料,解决了不少中文环境使用ChromeOS的问题,这里就分享一下Chromebook的选购和软件安装。

ChromeOS是什么

ChromeOS是Google从2009年开始开发的项目,可以简单理解为在Linux内核上运行的,以Chrome浏览器作为桌面环境,主要功能也是运行Web应用程序的一个操作系统。在之后,该系统也支持了运行Android应用程序,以及通过容器运行Linux程序,这样一套组合下来,我们就获得了一个原生支持运行Android应用,Linux应用和Web应用的系统,这比在Windows下面折腾Linux子系统,Android子系统要流畅得多。

目前为止,想要运行ChromeOS有两种方式,第一种就是购买ChromeBook,也就是搭载了ChromeOS的笔记本电脑或者触屏电脑。第二种方式,Google在2022年发布了ChromeOS Flex,让用户可以在经过认证的设备上安装ChromeOS Flex,包括一些Mac电脑也是支持的。

而想要激活ChromeOS,你需要有可以顺畅访问Google服务的网络。如果你没有这个条件,来自中国的fydeOS它是一个本地化的ChromeOS,内置了本地化定制和国内可以使用的网络服务,感兴趣可以去他们的官网看看。

Chromebook适合谁

ChromeOS最初设计出来也主要是方便云端办公,提供简单、快速、安全的环境,因此它更适合于对于性能没有要求,而希望简单吗体验的人。比如说:使用在线文档的文字工作者,得益于Google doc,飞书文档,语雀等文字和表格类在线工具,Chromebook简单的功能以及比较长的续航是一个性价比比较高的选择。除此之外,对于性能没有要求的开发者和数码极客或许也会考虑由于一台自己的Chromebook。

最新的Chromebook有两档标准,普通的Chromebook,以及Chromebook Plus,普通的Chromebook可能只搭载Intel Celeron处理器以及4GB的ROM, Plus也只是它性能的两到三倍。目前Chromebook在国内没有销售,通过天猫国际等平台平台购买的新机器一般也都比较贵没有性价比。对于普通用户国内平台在销售的平板电脑或者笔记本都比它有性价比的多。

而对于我所说的极客用户来说,在闲鱼淘一台洋垃圾Chromebook可能是一个比较有性价比的选择。比如我这台Lenovo Duet5,骁龙7C,8GB内存,256GB存储,13寸的OLED屏幕,搭配触控笔加键盘,支持平板模式和桌面模式,只要不到1500块钱,相比于iPad,看起来还是有点性价比的。

Chromebook选购指南

再次强调一下选择Chromebook需要保证有能够激活Google服务的网络环境。不具备的可以考虑fydeos,以及他们的Fydetab Duo设备。

在淘设备的时候,因为我们可能买到的是2019年或者更早发布的设备,我们需要关注设备的自动更新到期时间(简称AUE),所有ChromeOS设备都能够借助于Chrome浏览器几乎同步的更新节奏收到Google的定期更新。因此决定购买之前可以在Google的这个页面看一下该产品型号的AUE日期。

其次,电池健康度也是选择二手Chromebook产品时候值得关注的信息。本身购买Chromebook就是为了优秀的能耗和续航体验,电池不行这些就没办法完全达成了。购买前最好和商家沟通让对方打开「关于 ChromeOS > 诊断」界面并提供截图,可以在这个界面中清楚地看到当前设备的电池寿命、循环计数等信息。从这里可以大概预估该设备之前的运行时长,并且电池寿命高于90%应该是比较好的。我在这里就踩到了坑,因为是专门的二手商家,说了是库存设备,并且说没法激活设备不愿意提供截图导致我收到的设备实际上电池已经循环过了300多次,电池寿命只有86%,同时因为运行时间过长oled屏幕也有一点烧屏了。

最后,屏幕这块OLE屏幕可以让卖家把屏幕亮度跳到最低拍照这样也能看到一部分屏幕的缺陷,以及全白页面拍照测试等。关于型号的话,考虑到Android应用的兼容性,我选择的是ARM芯片的Duet设备,如果更加关注Linux应用的兼容性或许可以考虑X86芯片的设备。设备的型号这块,除了我提到的Duet,Google推出的Pixelbook Go, Pixelbook也是可以考虑的型号。

最后的最后,实际购买之前可以考虑使用现有设备刷如ChromeOS Flex或者fydeOS体验一下再做决定。

ChromeOS 初始化

ChromeOS本身的内核是Linux,但是为了安全,我们是没办法在上面安装Linux应用的,同时Android应用的安装也必须通过Play store才能安装,因此如果想要获得系统的完全控制权是需要开启开发者模式的。开启开发者模式后可以直接安装Android APK文件,同时也拥有了Root权限,可以在系统做修改,比如安装类似Mac下面homebrew的chromebrew工具等。但是代价是,每次启动电脑都会先跳出一个60s的警告页面(可以跳过),而在普通模式和开发者模式之间切换,所有的系统数据都会被清除,需要提前做好备份。

在我体验完开发者模式之后,决定还是回到安全模式。对于大部分人也都不需要开发者模式,我们通过Linux子系统开启Linux子系统的开发者模式,也就可以通过ADB来安装Android应用。因此如果想要开启开发者模式可以查看网上的资料。 初始化,可以通过家庭的软路由,或者手机上面开启Clash作为代理服务,在连接完网络后,修改网络中的代理服务,把手机或者软路由作为Chromebook的代理服务器,从而可以激活服务。同时要系统更新和安装Linux子系统需要稳定的翻墙服务,不然可能会失败。

ChromeOS初体验

ChromeOS内已经内置了一部分Web应用,包括了Google全家桶和一些工具应用。在未连接键盘鼠标前是平板模式,连接了之后为桌面模式。

以上为桌面模式,打开两个应用平铺,左下角为应用列表。

以上为平板模式的桌面

很多场景也可以通过浏览器进行,对于一些提供了PWA的网站,可以点击地址栏的安装按钮,这样就会生成一个桌面图标方便下次使用。也可以在Chrome应用商店安装扩展程序。

因为登录了Google账号,Chrome浏览器上安装的扩展程序,一些设置,书签等也都会同步过来。

同时ChromeOS还支持与Android手机连接,能够对手机进行简单的控制,包括手机的静音,地理位置权限开关,控制手机的热点并连接上网,查看手机最近的照片,打开的Chrome标签页等,如下图所示。

对于中文输入,Chrome内置了拼音输入法,如果使用双拼的话可以考虑使用fydeos提供的真文韵输入法,不过真文韵输入法没有软键盘,在平板模式还是没法使用,另外真文韵在Linux应用也无法使用,解决方法后面再说。

配置Linux子系统

Linux系统模式是未开启的,需要首先到「关于 ChromeOS 」中开发者部分开启,最新版本默认安装的是Debian 12,因为需要到Google的服务器上下载Linux镜像文件,这个过程可能非常慢,我这里差不多半个小时才完成。

有了Linux系统,我们首先需要安装中文环境,执行如下命令安装中文字体:

1
sudo apt install fonts-wqy-microhei fonts-wqy-zenhei fonts-noto-cjk

Linux上面是没法使用系统的输入法的,我们需要安装Linux的中文输入法,我这里就是安装的fcitx5,可以使用如下命令安装:

1
sudo apt install zenity fcitx5 fcitx5-rime

安装之后在 /etc/environment.d/ 文件中创建一个im.conf文件,并且写入如下内容:

1
2
3
4
GTK_IM_MODULE=fcitx
QT_IM_MODULE=fcitx
XMODIFIERS=@im=fcitx
SDL_IM_MODULE=fcitx

之后手动打开fcitx5,并且配置好自己的输入法选项就可以在Linux中使用应用了。

除此之外,就跟正常使用linux一样,安装的Linux应用如果是有桌面图标的也会在Chrome的应用列表中展示,同样对于deb文件,也可以直接在chrome的文件管理器中直接点击安装。

现在ChromeOS也支持了安装多个容器,也就是说可以运行多个不同的Linux,感兴趣的可以看看这位博主的这篇安装ArchLinux的文章

安装微信

微信算是每个人都必须有的通信工具了,在ChromeOS中有两种方式可以安装,一个是安装到Android子系统,直接在Google play下载就行了,另一种则是安装Linux版本的桌面微信。

但既然有这么大的屏幕,当然是桌面版使用体验更好了。我这里介绍一下在我的Debian12下面安装arm版本的微信的过程吧,因为微信的有一些依赖系统内是没有的组件需要安装。

1
sudo apt install libatomic1 -y && wget -O libwebp6.deb https://security.debian.org/pool/updates/main/libw/libwebp/libwebp6_0.6.1-2.1+deb11u2_arm64.deb && sudo dpkg -i libwebp6.deb

除了这个之外还缺少一个libtiff5,debian12上面已经有libtiff6了,我们创建一个链接就可以了。

1
sudo ln -s /usr/lib/aarch64-linux-gnu/libtiff.so.6 /usr/lib/aarch64-linux-gnu/libtiff.so.5

之后我们应该就可以使用Linux版本的微信了。

另外还推荐在Linux子系统安装stalonetray,这样就可以展示Linux的软件的托盘,比如去查看输入法状态,和切换输入选项等。可以参考这篇文章

对于Linux直接在Chrome点击deb文件安装的应用,虽然安装完了但是有可能点击图标打开的时候总是在转圈却打不开,这可能是因为程序出错了,可以在命令行中手动运行,这样错误日志就可以查看了。

配置安装非Google play的Android应用

如果想要安装国内的应用,可能很多都无法在Google play商店下载,一种方式是打开ChromeOS的开发者模式,但是那样每次开机就要忍受开机警告。我这里选择通过Linux子系统来安装。

首先打开「关于 ChromeOS -> Linux开发环境 -> 开发Android应用」,将其中的启用ADB调试打开。

点击启用的时候会有如下提示:

并且如果停用的话也会将Chromebook恢复出厂设置,所有数据被清空,使用这个功能需要谨慎。

再打开Linux命令行,执行如下命令安装adb工具。

1
sudo apt install adb

之后打开「设置 -> 应用 -> 管理Google Play 偏好设置 -> Android设置」,这样就进入Android系统的设置应用中了,可以在关于设备中多次点击版本号,开启Android子系统的开发者模式,在然后到系统,开发者选项中打开ADB调试。之后在linux命令行执行如下命令并显示正常就说明配置好了。

1
adb devices

之后就可以通过ADB安装程序了,当然也可以选择使用adb安装一个国内的应用商店,之后通过应用商店安装应用。

ChromeOS的体验介绍

使用了一段时间之后来说,作为一个轻量化的Linux 本来说,这台设备还是符合期望的。Linux,Android子系统都和宿主系统有着很好的深度绑定,使用子系统的应用也和使用宿主一样有着很好的体验。而在我这里一大缺陷为,因为Linux子系统和Android子系统都被划分到了私有网络,因此它们实际上网络是和Chromeos宿主共享的,但是和局域网的其他设备不是在同一个子网里面的,因此类似LocalSend这种工具是不能使用的。这里目前我的解决办法是使用fydeOS提供的fyDrop工具和其他局域网进行文件传输。

这个设备同时还支持通过usb typec接口连接外接显示器,chromeos有着不错的窗口管理,桌面分屏,这些功能都为使用体验加分许多。

如果只是轻办公我感觉这是一台很棒的设备,但是得益于这个性能,想要在这上面做Android开发,去编译程序那就不敢想象了。而至于要不要入坑,还是要你自己决定。

最后照例来推荐一些可以参考的资料:

  1. fydeOS源于chromeOS,这边的中文资料都可以参考:https://community.fydeos.com/t/topic/40986
  2. Chrome 官方的文档: https://chromeos.dev/en/linux
  3. 解锁开发者模式和一些折腾,可以参考这边文章和博主的其他文章: 打造一台适合生产的Chromebook

看完评论一下吧

使用Leafletjs实现足迹地图功能

2025年2月9日 11:40

我的博客上面挂着一个使用Leaflet实现的足迹地图功能,最近又给他添加了一些功能并且进行了一些美化。之前也有人问题这个怎么实现的,趁着刚折腾完来分享一下。

代码库的选择

早前一直想要做一个足迹的功能,像是国内的百度地图和阿里地图都有js的sdk,但是他们的sdk使用不简单,并且他们的地图只有国内的。后来了解过google map以及mapbox,但是都没有深入研究。后来看到博主水八口记使用了leaflet还比较简单,就使用这个库来实现了我的足迹功能。

地图图层

使用leaflet的一大好处是,你可以自由使用你想要的地图图层,对于符合Leaflet的地图瓦片地址我们是可以直接使用的,通常是类似这种格式的地址: https://{s}.somedomain.com/{foo}/{z}/{x}/{y}.png,其中的{z}/{x}/{y}是必须要支持的,leaflet会在运行的时候替换具体的值,从而请求对应的放大级别(z,zoom), 对应坐标(x, y)的瓦片进行渲染。

一般使用cartocdn提供的openstreetmap的地图时,是可以直接使用的,但是我们如果想要使用mapbox地图或者其他地图供应商的时候,就需要借助插件了,可以在这个页面看看有没有Plugins - Leaflet - a JavaScript library for interactive maps

对于地图图层,leaflet是支持同时加载多个图层的,比如说我可以添加一层底图再添加一层天气卫星图。

我们这里先看一下如何创建一个地图并且设置我们的地图图层. 首先需要引入leaflet的css和js文件

1
2
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<!-- js引入一定要放到css的后面 --> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>

之后,在我们需要显示地图的位置放一个div元素,并且设置一个id,这样我们在后面的js代码中才能控制它:

1
<div id="footprintmap"></div>

同时我们可以通过css设置这个容器的宽度高度:

1
2
3
4
#footprintmap {
width: 100%;
 height: 180px;
}

这些做完之后就可以在javascript中去创建地图对象,并且给它添加地图图层了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<script type="text/javascript">

 //地图的版权声明,使用三方地图数据出于对版权的尊重最好加一下
      var cartodbAttribution = '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors, &copy; <a href="https://carto.com/attribution" target="_blank">CARTO</a>';
      var map = L.map('map', {gestureHandling: true, minZoom: 1, maxZoom: 14}).setView([33.3007613,117.2345622], 4); //创建地图,设置最大最小放大级别,setView设置地图初始化时候的中心点坐标和放大级别
      map.zoomControl.setPosition('topright'); //设置放大控制按钮的位置
      map.createPane('labels');

      map.getPane('labels').style.zIndex = 650;

      map.getPane('labels').style.pointerEvents = 'none';

      L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png', {

    attribution: cartodbAttribution

}).addTo(map); //添加地图图层到map对象当中

</script>

添加足迹点到地图中

经过以上的步骤我们就可以在网页上展示一个地图了,而我们实现足迹功能一般会给我们去过的地点打上标记。一种方法是给去过的城市做一个蒙层,一种方式是加一些点标记。这里先看加点标记的方法。

标记在Leaflet中称为Marker, 我们可以使用以下代码添加默认的Market:

1
marker = new L.marker([33.3007613,117.2345622]).bindPopup("popup text").addTo(map);

效果如下:

在上面我们通过bindPopup来设置点击Marker之后弹出的内容,其中我们是可以设置HTML元素的,因此我们就可以显示图片或者超链接之类的内容了。

如果不喜欢这个默认的蓝色Marker,也可以替换为图片。比如我用如下的代码就设置类一个svg图片作为Market标记图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function colorMarker() {
  const svgTemplate = `
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="marker">
      <path fill-opacity=".25" d="M16 32s1.427-9.585 3.761-12.025c4.595-4.805 8.685-.99 8.685-.99s4.044 3.964-.526 8.743C25.514 30.245 16 32 16 32z"/>
      <path stroke="#fff" fill="#ff471a" d="M15.938 32S6 17.938 6 11.938C6 .125 15.938 0 15.938 0S26 .125 26 11.875C26 18.062 15.938 32 15.938 32zM16 6a4 4 0 100 8 4 4 0 000-8z"/>
    </svg>`;
  const icon = L.divIcon({
    className: "marker",
    html: svgTemplate,
    iconSize: [28, 28],
    iconAnchor: [12, 24],
    popupAnchor: [7, -16],
  });
  return icon;
}

marker = new L.marker([lat, lng], {
    icon: colorMarker(),
  }).bindPopup(popupText).addTo(map);

主要是在前面创建marker的时候传的这个icon,你也可以传普通的图片。

如果我们需要展示多个点的时候,我们可以把这些点的数据存储成一个json,并且把他作为一个JavaScript对象加载,再读取他把每个点添加到地图中。 我就创建了一个points.js的文件保存所有的点:

1
2
3
let points = [
    ["<b>北京</b><i>Beijing</i><a href='/2025-01-beijing/'><img src='https://img.isming.me/photo/IMG_20250101_133455.jpg' />北京游流水账</a>", 40.190632,116.412144],
    ["<b>广州</b><i>Guangzhou</i>", 23.1220615,113.3714803],];

内容大概如上:

1
2
<!--加载点数据这样我们在javascript环境中就可以拿到points这个数组-->
 <script type="text/javascript" src="/points.js"></script>

以上加载了点数据,通过下面的代码来读取并且添加点:

1
2
3
4
5
6
7
for (let i = 0; i < points.length; i++) {
//循环遍历所有点,并且保存到如下三个变量中
  const [popupText, lat, lng] = points[i];
  marker = new L.marker([lat, lng], {
    icon: colorMarker(),
  }).bindPopup(popupText).addTo(map);
}

到此为止就完成了足迹点功能的开发。

去过的区域图层开发

而我们要实现去过的城市标记,这个时候就不是一个一个的点了,我们可能给去过的城市添加遮罩,这个其实就是给地图上画一个新的图层。每一个城市本质上就是许多个点围成的多边形,我们可以使用Leaflet提供的polygon方法来绘制,但是我们需要给把每个城市的多边形的各个顶点找到并且组织成一个数组,工作量真的是巨大的。

这样的难题我们不是第一个遇到的,前人已经遇到并且帮我们解决了。在2015年就有了GeoJson这种用Json描述的地理空间数据交换格式,他支持描述点,线,多边形。而Leaflet对齐也有支持。因此,我们只需要找到我们所需要的城市的geojson数据的MultiPolygon或者Polygon数据,就可以在Leaflet中进行绘制了。

对于中国的数据,我们可以在阿里云的datev平台进行下载,你可以省份数据或者按照城市甚至更小的行政单位下载数据。对于国外的数据可以到github上面去查找,这里是一份国家数据: datasets/geo-countries: Country polygons as GeoJSON in a datapackage

对于我们下载的中国的geojson数据,因为比较详细,也就比较大,我们可以使用mapshaper这个工具来对数据进行一些处理,直接使用Simplify功能,使用它减少点的数量,从而减少我们的文件的大小。

按照geojson文件格式,我们对于多个城市需要组成一个类似如下的json:

1
2
3
4
5
6
{
"type": "FeatureCollection", features: [
{"type": "Feature", "geometry": {"type": "Polygon", "coordinates": [[[88.40590939643968,22.55522906690669],[88.36498482718275,22.494854169816982],[88.28898205570562,22.51497913551355],[88.2714429545955,22.55235407180718],[88.32990662496253,22.55235407180718],[88.36498482718275,22.60410398359836],[88.35913846014606,22.62997893949395],[88.38837029532957,22.62710394439444],[88.40590939643968,22.55522906690669]]]}},
...
]
}

对于这样的一个json对象,我们就可以直接使用Leaflet的geojson文件进行加载,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function onEachFeature(feature, layer) { // does this feature have a property named popupContent?
 if (feature.properties && feature.properties.popupContent) {
 layer.bindPopup(feature.properties.popupContent); //从json文件中读取属性进行popup展示
 }
}

var geojson = L.geoJSON(areas, {
 onEachFeature: onEachFeature,
  style: function (geoJsonFeature) {
    return {
      color: '#ffcc80', //设置遮罩的颜色
      fillOpacity: 0.4, //设置透明度
      stroke: false, //是否要显示边缘线
    };
  }
}).addTo(map);

对于geojson我们也可以在properties中设置弹框的内容进行展示。

总结

到这里我们就完成了基于leaflet的一个足迹地图,既包括足迹点,也包括去过的城市的遮罩。而geojson和Leaflet的功能远远不止这些,感兴趣的可以去看相关文档。另外因为我使用的地图是openstreetmap的数据,关于中国领土有争议的部分标记不正确,这个不在我的解决能力范围之内,只能暂且使用,但是不代表本人观点。

参考资料:

  1. Tutorials - Leaflet - a JavaScript library for interactive maps
  2. https://tomickigrzegorz.github.io/leaflet-examples/
  3. GeoJSON - 维基百科,自由的百科全书
  4. DataV.GeoAtlas地理小工具系列

看完评论一下吧

强大的壳-Shell Script

2024年12月26日 13:45

Shell脚本我们经常会使用,平时自己折腾Nas会用到,工作中为了配置CI会用到,自己的电脑上最近为了配置自己的命令行环境也要使用shell来进行配置。不过之前的shell功力都来自每次使用的时候网上搜索,于是最近就找了一本《Linux命令行与shell脚本编程大全》看了看,看完之后更加感受到Shell的强大,特地写个文章来分享一下。

首先呢,shell它也是一种语言,不过因为使用到的shell环境不同语法会有一些差异,在Linux上我们常用的shell是Bash,在Mac上面常用的shell为zsh,大体的语法相似的。编程语言的基本要素,Shell都是支持的,它支持变量,支持if判断,case选择,循环等结构化的编程逻辑控制,也支持基本的算数运算,同时还支持使用函数来复用代码。 简单介绍一下它的语法,首先是变量。系统为我们已经提供了很多的变量,同时在我们的配置文件中定义的那些变量也是可以读取到的。定义变量语法如下:

1
2
3
4
5
var=value #注意等号两边不能加空格
echo $var #使用的时候前面要加上$符号
echo ${var}

export varb=b #导出成为环境变量

以上方式定义的变量默认是全局的,比如你在一个函数中定义的,外面也能访问,这是时候可以定义局部变量:

1
local local_var=x #只能在函数中使用

除了普通的变量之外,shell中也是支持数组和Map的,当然要bash 4.0以上才能完整支持,使用如下:

1
2
declare -A info # 声明一个map
declare -a array #声明一个数组

而如果只是有这些东西的话,还不至于说Shell强大。而shell中可以直接调用命令以及Linux中的一些程序这才是它的强大之处。在python等其他语言中我们也是可以调用的,但是是都需要通过语言的系统调用才能调用,而shell中则是可以直接调用那些命令,只要这些程序的可执行文件在PATH环境变量中就可以。

而配合Shell的很多特性,又进一步强大了。第一大神器是重定向,重定向支持重定向输入和重定向输出,以下为一些示例:

1
2
3
4
5
6
7
date > test.txt #重定向输出到test.txt文件中,覆盖文件
ls >> test.txt #重定向,但是追加而不是覆盖文件
wc < test.txt #输入重定向
wc << EOF #内敛输入重定向
test a
test b
EOF

因为有了输入输出重定向,我们会有很多的玩法,可以方便的命令的输入写入到我们的文件中,而linux系统中,万物皆为文件,因此理论上可以写入或者读取所有东西。比如,有一个Null设备,我们可以通过以下的命令,来不展示任何运行输出。

1
2
ls >/dev/null 2>&1
ls 1>/dev/null 2>/dev/null

1为标准输出,2为错误输出,未指定的时候默认是把标准输出重定向,这里重定向到null则不会有任何输出,而第一行我们将错误输出又通过&绑定到了标准输出。当然除了这个还有更多的用法。

除了重定向之外的另一大特性则是 管道 。在某些场景重定向已经可以解决了很多功能,但是管道实现会更优雅。管道可以将前一个命令的输出直接传给另一个命令,并且管道的串联没有数量的限制,并且前一个命令产生输出就会传递到第二个命令,不用使用缓冲区或者文件。比如:

1
ls | sort | more

甚至我们还可以将刚刚的输出继续重定向保存到文件

1
ls | sort > files.txt

在很多命令的参数之类的都提供了正则表达式的支持,正则表达式能够让我们更加方便的进行数据匹配,Linux中常用正则为POSIX正则表达式,而它又有两种,基础正则表达式(BRE)和扩展正则表达式(ERE),大部分的Linux/Unix工具都支持BRE引擎规范,仅仅通过BRE就能完成大部分的文本过滤了,但是ERE提供了更强的功能,而有些工具为了速度,也仅仅实现了BRE的部分功能。

BRE支持的语法符号包括,.匹配任意一个字符,[]字符集匹配,[^]字符集否定匹配,^匹配开始位置, $匹配结束位置,()子表达式,*任意次数量匹配(0次或多次),而ERE在BRE的基础上,还支持?最多一次匹配,+匹配至少一次。而它们的更多功能可以参看这篇文章:https://en.wikibooks.org/wiki/Regular_Expressions/POSIX_Basic_Regular_Expressions

有了正则表达式以及许多的处理工具我们就可以做很多的事情了,比如说查找文件,我们可以使用find,查找某个文件夹下面为指定后缀的文件:

1
find . -type f -name "*.java" #find支持的只是通配符,非正则

而配合管道,又可以对find之后的结果进行进一步的处理,比如配合上grep可以进一步对文件的内容进行过滤。

1
2
find . -type f -name "*.sh" |xargs grep "bash" #find 不能通过管道直接传递可以使用xargs或者通过如下方式
find . -type f -name "*.sh" -exec grep "bash" {} \;

对于文本的处理,Linux中又有sed和awk两大杀器,而关于他们的使用已经可以被写成书了。sed全名为Stream editor,也就是流编辑器,通过它可以方便的查找文件内容并替换后输出,awk则是一种模式匹配和文字处理语言,通过他们可以方便的处理文本。比如说我们可以使用sed对一份CSV文件中的手机号码进行打码处理:

1
sed -E 's/([0-9]{3})[0-9]{4}([0-9]{4})/\1**\2/g' input.csv

以上关于命令的介绍只是抛砖引玉,关于他们的使用,我们的电脑中已经给我们提供了详细的介绍,只需要在命令行中输入man commandname就可以了,除此之外,很多的命令也也提供了简单的帮助,只需要输入commandname help, command --help之类的就可以看到。

如果仅仅是语言层面的功能的话,shell相比python是没什么优势的,但是它能够和其他的命令无缝的使用,并且被Mac,Linux,Unix内置可直接使用也是它的一大优势。此外我们还可以通过shell脚本来增强我们的Linux终端,比如说可以定义自己的函数,通过.bashrc引用,可以在终端中直接调用方法名执行。

通过Shell,在Linux下面的体验得到很好的提升,工作效率也可以获得很大的提高,本文只是略微提到其皮毛,希望能够引起你对Shell的兴趣,如果想要更加深入的了解,还是需要去阅读手册或者书籍。

以下是推荐的一些资料可供参考:

  1. Bash脚本编程入门 by阮一峰
  2. Bash脚本进阶指南
  3. Grep,Sek和awk的区别
  4. 《Linux命令行与Shell脚本编程大全》(可以在微信读书中看电子书)
  5. awesome-shell (值得看看的各种资料,也可以去看看别人写的shell脚本)

看完评论一下吧

Linux重装与dotfile整理分享

2024年12月15日 20:05

最近把电脑上面的Linux系统给重装了,同时呢也要配置新的MacBook,就整理了一个个人的dotfile,这里分享一下linux上的我主要使用的软件,以及我的dotfile内容。

什么是Dotfile

dotfile字面意思就是以.开头的文件,在Linux当中就是隐藏文件,我们大家说的一般指的就是配置文件,比如shell的.bashrc.profile文件等。我在整理自己的dotfile的时候参考了一些网上大神的dotfile文件,这里我主要是包含我的shell的一些配置文件,vimgitrime相关的文件。

我的 Dotfile

为了保持Linux和Mac系统的统一, 我将Linux的Shell也换成了zsh,同时为了简单并没有使用oh-my-zsh,只是添加了一些自己常用的aliases

而Vim则使用neovim,它相当于是重新开发的,我想比vim应该代码上面更加高效,毕竟少了很多的历史包袱。另外它的配置文件可以使用Lua进行编写,而不是使用vim script,这样也是它的一大优点。

除了配置之外,还增加了脚本用用于将这些配置文件自动拷贝到对应的目录,使用以下代码判断是Linux系统还是Mac系统:

1
2
3
4
5
if [ "$(uname -s)" == "Darwin" ]; then
 //action for mac
else
 //action for linux
fi

另外呢,对于Mac系统,在初始化脚本中还添加了homebrew的安装,并且通过使用Brewfile在定义需要安装的一些软件,这样在执行brew bundle的时候可以把这些软件都安装上去。

对于Linux的目前还没做啥,都是通过自己手动安装的,不过一些操作也记录到了shell文件当中了。

Linux上的软件

既然写了文章,就顺便分享一下我的Linux上面还在用的软件吧。 首先是Shell,为了跟Mac保持统一,也改用了zsh,如果你也想要设置zsh为你的默认shell,可以执行如下命令并重启(前提你已经安装的zsh):

1
 sudo chsh -s $(which zsh) $USER

编辑器目前在用的有三款,主要在用neovim,同时代码文件还会使用vscode,因为有些场景neovim操作比较麻烦(对于快捷键不太熟悉),最近也在使用阮一峰老师之前推荐过的zed,据说比vscode性能更高,目前体验是对于很多语言的支持是已经内置了,不需要在安装插件,这点是好评的。

输入法在使用Fcitx5,输入方案则是使用了Rime,Rime的配置则是参考了雾凇拼音,而我主要使用小鹤双拼。

其他还在使用的软件包括:

项目开发: Android studio

截图软件:Flameshot

启动器: ULauncher, 使用简单,支持的插件数量也比较多

文档搜索: Zeal doc,mac 上面dash的window和linux平台开源版本,支持dash的文档。

文件同步: Syncthing

局域网文件传输: LocalSend

聊天软件: Weixin, telegram

文档和博客编辑: Obsidian

网页浏览器: Edge

Linux 开启zram

我的电脑已经有32G的内存了,大部分时候是够用的,但是编译Android系统的时候就不够用了。因此需要想办法,一种方式是弄一个swap空间,但是swap的速度不是很快,经过查询资料了解到现在linux已经有了一种新的虚拟内存技术,也就是zram,它主要功能是虚拟内存压缩,它是通过在RAM中压缩设备的分页,避免在磁盘分页,从而提高性能。

而想要启用它其实很简单,在我们的Ubuntu中,只需要首先关闭原先的swap空间,编辑/etc/fstab文件,将其中的swapfile条目注释掉。之后调用如下命令:

1
sudo swapoff /swapfile

如果你本来就没有设置swap,那就不需要做上面的操作,直接安装zram-config:

1
2
3
sudo apt install zram-config
systemctl enable zram-config //设置开机启动开启zram的服务
systemctl start zram-config //启动zram服务

之后可以调用如下命令验证:

1
cat /proc/swaps

我们在系统监控里面也能看到,不过还是swap。 以上方式开启的zram为物理内存的一半大小,当然也是可以修改的。 修改/usr/bin/init-zram-swapping文件中的mem大小即可。

如果对于我的dotfile感兴趣,可以查看我的repo, 地址为: https://github.com/sangmingming/dotfiles,其中我提到的初始化脚本为script/bootstrap文件。

看完评论一下吧

威联通NAS购入初体验以及设置记录

2024年10月29日 21:57

之前是用树莓派连个两盘位硬盘盒运行一些服务,由于它的稳定性加上容量不够,一直想弄一个NAS,趁着双十一到来,就入手了威联通的NAS,本文介绍 一下购入的抉择以及NAS的初始化和相关的设置。

缘起

NAS这个东西知道了很多年了,一直想要搞一个,迫于家里花费的紧张,之前一直是使用一台树莓派4B,其中刷了Openwrt系统,挂载了两块盘的硬盘盒,其中开启了Webdav, Samba,Jellyfin相关的东西。不过因为Jellyfin挂载阿里云盘速度不太理想,有不少视频还是下载到自己的硬盘里面的。同时内,硬盘也出现了拷贝大文件就出现问题,需要重启硬盘盒和系统的问题,这个后续会继续说。

DIY NAS硬件或者成品的NAS也关注了有挺长一段时间,迫于以上问题,以及文件越来越多,当时买的这两块2T的硬盘,容量已经不够用了,想要购买一个NAS的想法更加加强,终于决定今年双十一搞个NAS。

剁手

购买NAS是有两个选择,自己组装硬件,安装飞牛或者黑群晖等NAS系统,又或者购买群晖、威联通等成品NAS。在V2EX发帖求助,以及自己的纠结中,最终在性价比和稳定性等各种因素比较之后,选择入手了威联通TS464C2。

威联通的系统虽然被大家诟病许久,但是它也算是市场上除了群晖之外NAS系统做的最久的厂家了,考虑到文件的安全可靠在文件系统和系统稳定性上面,这两家还是要比国内的新起之辈更加值得信赖的。而我选择的这一款,支持内存扩展,如果以后服务比较多,可以再增加一根内存。4个3.5寸硬盘位加上两个NVME 硬盘位,对于容量的扩展应该很多年都不存在问题了。双十一这块机器只要2000块钱就拿下,而群晖同配置的4盘位差不多要四千,只能说高攀不起。

另外下单了一块国产的NVME 2T硬盘,加入Qtier存储池,希望能提高一些速度。为了拥有更大的容量,经过一些研究,淘宝购入了一块2手服务器硬盘,型号为HC550, 16TB,回来看Smart信息,已经运行了786天,不过其他信息看着都不错。

上电

收到机器,插上硬盘,参照指南开始初始化。威联通提供了比较友好的初始化方法,可以通过网页或者应用对它进行初始化,不过一定要插上硬盘才能开始这一切。

根据指南初始化之后,开始了硬盘的初始化和存储池的设置。之前使用的openwrt中对于硬盘的管理是比较简单的,基本就是实现了基础的Linux功能,把磁盘挂载到指定的目录,硬盘初始化之类的。而QNAP中,“存储与快照总管应用”中,对于硬盘和存储卷的设置则全面,可以设置各种raid的存储池,Qtier,快照,卷等等,也有硬盘的运行情况的显示。我想这就是选择大厂成品NAS的原因,毕竟docker之类的东西大家都很容易做,但是这种积累了很多年的东西不是那么快能够做出来的。

安装软件

在威联通NAS中安装软件可以选择从QNAP的应用中心安装应用,也可以安装Container Station之后通过docker来安装。不过官方的应用中心中的应用中主要是一些官方提供的应用,这个时候我们可以选择第三方的应用中心,这里我推荐一个: https://www.myqnap.org/repo.xml,官方应用商店没有的可以来这里试试。不过这个应用商店中的部分应用是收费的,比如Jellyfin,它提供的版本需要依赖Apache,这个时候你需要去它的网站上面购买,价格还不便宜,当然我是不会去购买的。

除了应用中心安装之外,我们还可以去网上找QPKG文件,比如Jellyfin,我就是使用的pdulvp为QNAP定制的版本,下载地址在:https://github.com/pdulvp/jellyfin-qnap/releases。Jellyfin我不使用官方的docker版本有两个原因,一是使用这个定制版本,可以方便的使用英特尔的集成显卡进行视频的硬解,另一方面是使用docker的化,默认只能映射一个媒体目录到Docker中,想要映射多个目录会麻烦一点,因此使用QPKG更方便。

对于其他的应用,比如FreshRss, VaultWarden则是选择了使用Docker进行部署,Container Station的Docker部署为先写一个compose文件,之后软件会帮助下载相关的容器进行启动,这个有个问题就是创建完compose之后,容器启动起来之后,在web界面上就没法编辑compose文件了,想要编辑的需要用ssh进终端去看,比如我创建的app-freshrss它的compose文件就在/share/Container/container-station-data/application/app-freshrss当中。

另外威联通自带的一些应用,文件管理,QuMagie,特别要说一下QuMagie,它已经可以帮我把相片按照人物,地点,物品等等分类好了,配合上手机App使用流畅很多,再也不用之前那样使用SMB手动同步了。

其他

目前用了这个二手服务其硬盘加上新买的固态硬盘组了一个Qtier池作为主要存储区,家里有块旧的sata固态硬盘就把他搞成高速缓存加速了。原来的两块酷狼硬盘都是EXT4格式,但是插到QNAP上却不能识别,只好放在原来的设备上,新NAS通过Samba访问原先的设备,把文件拷贝过来。

之后把旧的硬盘插上来使用才发现,其中一个硬盘出现了坏道,数量还不少,感觉它应该命不久矣,不敢放什么东西上来了。 而另一块好的硬盘,准备把它作为备份盘,相片,笔记和其他的一些重要文件都定期备份到这个盘上面。因为硬盘数量优先,并没有组RAID还是空间优先,只把重要的文件备份但愿以后不会踩坑。

以上就是这个新NAS的初体验了,后面还要继续增加新的应用,仍然需要摸索,外网访问仍然沿用家里的DDNS和端口转发。目前才用了不到一个星期,还有很多东西没有用到没有涉及,后面有新的体验来再继续写文章分享,也欢迎玩NAS网友一起交流分享,如果有好玩的应用也欢迎评论推荐给我。

看完评论一下吧

Android源码分析:广播接收器注册与发送广播流程解析

2024年10月17日 19:40

广播,顾名思义就是把一个信息传播出去,在Android中也提供了广播和广播接收器BroadcastReceiver,用来监听特定的事件和发送特定的消息。不过广播分为全局广播和本地广播,本地广播是在Android Jetpack库中所提供,其实现也是基于Handler和消息循环机制,并且这个类Android官方也不推荐使用了。我们这里就来看看Android全局的这个广播。

应用开发者可以自己发送特定的广播,而更多场景则是接收系统发送的广播。注册广播接收器有在AndroidManifest文件中声明和使用代码注册两种方式,在应用的target sdk大于等于Android 8.0(Api Version 26)之后,系统会限制在清单文件中注册。通过清单方式注册的广播,代码中没有注册逻辑,只有PMS中读取它的逻辑,我们这里不进行分析。

注册广播接收器

首先是注册广播接收器,一般注册一个广播接收器的代码如下:

1
2
3
val br: BroadcastReceiver = MyBroadcastReceiver()
val filter = IntentFilter(ACTION_CHARGING)
activity.registerReceiver(br, filter)

使用上面的代码就能注册一个广播接收器,当手机开始充电就会收到通知,会去执行MyBroadcastReceiveronReceive方法。

那我们就从这个registerReceiver来时往里面看,因为Activity是Context的子类,这个注册的方法的实现则是在ContextImpl当中,其中最终调用的方法为registerReceiverInternal,代码如下:

 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
private Intent registerReceiverInternal(BroadcastReceiver receiver, int userId,
 IntentFilter filter, String broadcastPermission,
 Handler scheduler, Context context, int flags) {
 IIntentReceiver rd = null;
 if (receiver != null) {
 if (mPackageInfo != null && context != null) {
 if (scheduler == null) {
 scheduler = mMainThread.getHandler();
 }
 rd = mPackageInfo.getReceiverDispatcher(
 receiver, context, scheduler,
 mMainThread.getInstrumentation(), true);
 } else {
 ...
 }
 }
 try {
 ActivityThread thread = ActivityThread.currentActivityThread();
 Instrumentation instrumentation = thread.getInstrumentation();
 if (instrumentation.isInstrumenting()
 && ((flags & Context.RECEIVER_NOT_EXPORTED) == 0)) {
 flags = flags | Context.RECEIVER_EXPORTED;
 }
 final Intent intent = ActivityManager.getService().registerReceiverWithFeature(
 mMainThread.getApplicationThread(), mBasePackageName, getAttributionTag(),
 AppOpsManager.toReceiverId(receiver), rd, filter, broadcastPermission, userId,
 flags);
 if (intent != null) {
 intent.setExtrasClassLoader(getClassLoader());
 intent.prepareToEnterProcess(
 ActivityThread.isProtectedBroadcast(intent),
 getAttributionSource());
 }
 return intent;
 } catch (RemoteException e) {
 ...
 }
}

我们在注册广播的时候只传了两个参数,但是实际上它还可以传不少的参数,这里userId就是注册的用户id,会被自动 填充成当前进程的用户Id,broadcastPermission表示这个广播的权限,也就是说需要有该权限的应用发送的广播,这个接收者才能接收到。scheduler就是一个Handler,默认不传,在第8行可以看到,会拿当前进程的主线程的Handlerflag是广播的参数,这里比较重要的就是RECEIVER_NOT_EXPORTED,添加了它则广播不会公开暴露,其他应用发送的消息不会被接收。

在第10行,这里创建了一个广播的分发器,在24行,通过AMS去注册广播接收器,只有我们的broadcast会用到contentprovider或者有sticky广播的时候,30行才会执行到,这里跳过。

获取广播分发器

首先来看如何获取广播分发器,这块的代码在LoadedApk.java中,代码如下:

 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
public IIntentReceiver getReceiverDispatcher(BroadcastReceiver r,
 Context context, Handler handler,
 Instrumentation instrumentation, boolean registered) {
 synchronized (mReceivers) {
 LoadedApk.ReceiverDispatcher rd = null;
 ArrayMap<BroadcastReceiver, LoadedApk.ReceiverDispatcher> map = null;
 if (registered) {
 map = mReceivers.get(context);
 if (map != null) {
 rd = map.get(r);
 }
 }
 if (rd == null) {
 rd = new ReceiverDispatcher(r, context, handler,
 instrumentation, registered);
 if (registered) {
 if (map == null) {
 map = new ArrayMap<BroadcastReceiver, LoadedApk.ReceiverDispatcher>();
 mReceivers.put(context, map);
 }
 map.put(r, rd);
 }
 } else {
 rd.validate(context, handler);
 }
 rd.mForgotten = false;
 return rd.getIIntentReceiver();
 }
}

先来说一下mReceivers,它的结构为ArrayMap<Context, ArrayMap<BroadcastReceiver, ReceiverDispatcher>>,也就是嵌套了两层的ArrayMap,外层是以Context为key,内层以Receiver为key,实际存储的为ReceiverDispatcherReceiverDispatcher内部所放的IIntentReceiver比较重要,也就是我们这个方法所返回的值,它实际是IIntentReceiver.Stub,也就是它的Binder实体类。

这段代码的逻辑也比较清晰,就是根据ContextReceiver到map中去查找看是否之前注册过,如果注册过就已经有这个Dispatcher了,如果没有就创建一个,并且放到map中去,最后返回binder对象出去。

AMS注册广播接收器

在AMS注册的代码很长,我们这里主要研究正常的普通广播注册,关于黏性广播,instantApp的广播,以及广播是否导出等方面都省略不予研究。以下为我们关注的核心代码:

 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
public Intent registerReceiverWithFeature(IApplicationThread caller, String callerPackage,
 String callerFeatureId, String receiverId, IIntentReceiver receiver,
 IntentFilter filter, String permission, int userId, int flags) {
 ...
 synchronized(this) {
 ReceiverList rl = mRegisteredReceivers.get(receiver.asBinder());
 if (rl == null) {
 rl = new ReceiverList(this, callerApp, callingPid, callingUid,
 userId, receiver);
 if (rl.app != null) {
 final int totalReceiversForApp = rl.app.mReceivers.numberOfReceivers();
 if (totalReceiversForApp >= MAX_RECEIVERS_ALLOWED_PER_APP) {
 throw new IllegalStateException("Too many receivers, total of "
 + totalReceiversForApp + ", registered for pid: "
 + rl.pid + ", callerPackage: " + callerPackage);
 }
 rl.app.mReceivers.addReceiver(rl);
 } else {
 try {
 receiver.asBinder().linkToDeath(rl, 0);
 } catch (RemoteException e) {
 return sticky;
 }
 rl.linkedToDeath = true;
 }
 mRegisteredReceivers.put(receiver.asBinder(), rl);
 } else {
 // 处理userId, uid,pid 等不同的错误
 }

 BroadcastFilter bf = new BroadcastFilter(filter, rl, callerPackage, callerFeatureId,
 receiverId, permission, callingUid, userId, instantApp, visibleToInstantApps,
 exported);
 if (rl.containsFilter(filter)) {
 } else {
 rl.add(bf);
 mReceiverResolver.addFilter(getPackageManagerInternal().snapshot(), bf);
 }
 }
 ...
}

在前面ContextImpl中调用AMS注册Reciever的地方,我们传的就是Receiver的Binder实体,这里拿到的是binder引用。在代码中我们可以看到,首先会以我们传过来的receiver的binder对象为key,到mRegisterReceivers当中去获取ReceiverList,这里我们就知道receiver在System_server中是怎样存储的了。如果AMS当中没有,会去创建一个ReceiverList并放置到这个map当中去,如果存在则不需要做什么事情。但是这一步只是放置了Receiver,而我们的Receiver对应的关心的IntentFilter还没使用,这里就需要继续看31行的代码了。在这里这是使用了我们传过来的IntentFilter创建了一个BroadcastFilter对象,并且把它放到了ReceiverList当中,同时还放到了mReceiverResolver当中,这个对象它不是一个Map而是一个IntentResolver,其中会存储我们的BroadcastFilter,具体这里先不分析了。 BroadcastReceiver 存放结构

到这里我们就看完了广播接收器的注册,在App进程和System_Server中分别将其存储,具体两边的数据结构如上图所示。这里可以继续看看发送广播的流程了。

发送广播

一般我们发送广播会调用如下的代码:

1
2
3
4
5
Intent().also { intent -> 
 intent.setAction("com.example.broadcast.MY_NOTIFICATION") 
 intent.putExtra("data", "Nothing to see here, move along.") 
 activity.sendBroadcast(intent)
}

我们通过设置Action来匹配对应的广播接收器,通过设置Data或者Extra,这样广播接收器中可以接收到对应的数据,最后调用sendBroadcast来发送。而sendBroadcast的实现也是在ContextImpl中,源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Override
public void sendBroadcast(Intent intent) {
 warnIfCallingFromSystemProcess();
 String resolvedType = intent.resolveTypeIfNeeded(getContentResolver());
 try {
 intent.prepareToLeaveProcess(this);
 ActivityManager.getService().broadcastIntentWithFeature(
 mMainThread.getApplicationThread(), getAttributionTag(), intent, resolvedType,
 null, Activity.RESULT_OK, null, null, null, null /*excludedPermissions=*/,
 null, AppOpsManager.OP_NONE, null, false, false, getUserId());
 } catch (RemoteException e) {
 throw e.rethrowFromSystemServer();
 }
}

这里代码比较简单,就是直接调用AMS的broadcastIntentWithFeature来发送广播。

AMS发送广播

这里我们可以直接看AMS中的broadcastIntentWithFeature的源码:

 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
@Override
public final int broadcastIntentWithFeature(IApplicationThread caller, String callingFeatureId,
 Intent intent, String resolvedType, IIntentReceiver resultTo,
 int resultCode, String resultData, Bundle resultExtras,
 String[] requiredPermissions, String[] excludedPermissions,
 String[] excludedPackages, int appOp, Bundle bOptions,
 boolean serialized, boolean sticky, int userId) {
 enforceNotIsolatedCaller("broadcastIntent");
 synchronized(this) {
 intent = verifyBroadcastLocked(intent);

 final ProcessRecord callerApp = getRecordForAppLOSP(caller);
 final int callingPid = Binder.getCallingPid();
 final int callingUid = Binder.getCallingUid();

 final long origId = Binder.clearCallingIdentity();
 try {
 return broadcastIntentLocked(callerApp,
 callerApp != null ? callerApp.info.packageName : null, callingFeatureId,
 intent, resolvedType, resultTo, resultCode, resultData, resultExtras,
 requiredPermissions, excludedPermissions, excludedPackages, appOp, bOptions,
 serialized, sticky, callingPid, callingUid, callingUid, callingPid, userId);
 } finally {
 Binder.restoreCallingIdentity(origId);
 }
 }
}

第10行代码,主要验证Intent,比如检查它的Flag,检查它是否传文件描述符之类的,里面的代码比较简单清晰,这里不单独看了。后面则是获取调用者的进程,uid,pid之类的,最后调用broadcastIntentLocked,这个方法的代码巨多,接近1000行代码,我们同样忽略sticky的广播,也忽略顺序广播,然后来一点一点的看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//ActivityManagerService.java 
//final int broadcastIntentLocked(...)
intent = new Intent(intent);
intent.addFlags(Intent.FLAG_EXCLUDE_STOPPED_PACKAGES);
if (!mProcessesReady && (intent.getFlags()&Intent.FLAG_RECEIVER_BOOT_UPGRADE) == 0) {
 intent.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY);
}
userId = mUserController.handleIncomingUser(callingPid, callingUid, userId, true,
 ALLOW_NON_FULL, "broadcast", callerPackage);
final String action = intent.getAction();

首先这里的代码是对Intent做一下封装,并且如果系统还在启动,不允许启动应用进程,以及获取当前的用户ID,大部分情况下,我们只需要考虑一个用户的情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
if (action != null) {
 ...
 switch (action) {
 ...
 case Intent.ACTION_PACKAGE_DATA_CLEARED:
 {
 Uri data = intent.getData();
 String ssp;
 if (data != null && (ssp = data.getSchemeSpecificPart()) != null) {
 mAtmInternal.onPackageDataCleared(ssp, userId);
 }
 break;
 }
 case Intent.ACTION_TIMEZONE_CHANGED:
 mHandler.sendEmptyMessage(UPDATE_TIME_ZONE);
 break;
 ...
 }
}

对于一些系统的广播事件,除了要发送广播给应用之外,在AMS中,还会根据其广播,来调用相关的服务或者执行相关的逻辑,也会在这里调用其代码。这里我罗列了清除应用数据和时区变化两个广播,其他的感兴趣的可以自行阅读相关代码。

1
2
3
4
5
6
int[] users;
if (userId == UserHandle.USER_ALL) {
 users = mUserController.getStartedUserArray();
} else {
 users = new int[] {userId};
}

以上代码为根据前面拿到的userId,来决定广播要发送给所有人还是仅仅发送给当前用户,并且把userId保存到users数组当中。

获取广播接收者

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
List receivers = null;
List<BroadcastFilter> registeredReceivers = null;
if ((intent.getFlags() & Intent.FLAG_RECEIVER_REGISTERED_ONLY) == 0) {
 receivers = collectReceiverComponents(
 intent, resolvedType, callingUid, users, broadcastAllowList);
}
if (intent.getComponent() == null) {
 final PackageDataSnapshot snapshot = getPackageManagerInternal().snapshot();
 if (userId == UserHandle.USER_ALL && callingUid == SHELL_UID) {
 ...
 } else {
 registeredReceivers = mReceiverResolver.queryIntent(snapshot, intent,
 resolvedType, false /*defaultOnly*/, userId);
 }
}

以上为获取我们注册的所有的接收器的代码,其中FLAG_RECEIVER_REGISTERED_ONLY意味着仅仅接收注册过的广播,前面在判断当前系统还未启动完成的时候有添加这个FLAG,其他情况一般不会有这个Flag,这里我们按照没有这个flag处理。那也就会执行第4行的代码。另外下面还有从mReceiverResolver从获取注册的接收器的代码,因为大部分情况不是从shell中执行的,因此也忽略了其代码。

首先看collectReceiverComponents的代码:

 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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
private List<ResolveInfo> collectReceiverComponents(Intent intent, String resolvedType,
 int callingUid, int[] users, int[] broadcastAllowList) {
 int pmFlags = STOCK_PM_FLAGS | MATCH_DEBUG_TRIAGED_MISSING;

 List<ResolveInfo> receivers = null;
 HashSet<ComponentName> singleUserReceivers = null;
 boolean scannedFirstReceivers = false;
 for (int user : users) {
 List<ResolveInfo> newReceivers = mPackageManagerInt.queryIntentReceivers(
 intent, resolvedType, pmFlags, callingUid, user, true /* forSend */); //通过PMS,根据intent和uid读取Manifest中注册的接收器
 if (user != UserHandle.USER_SYSTEM && newReceivers != null) {
 for (int i = 0; i < newReceivers.size(); i++) {
 ResolveInfo ri = newReceivers.get(i);
 //如果调用不是系统用户,移除只允许系统用户接收的接收器
 if ((ri.activityInfo.flags & ActivityInfo.FLAG_SYSTEM_USER_ONLY) != 0) {
 newReceivers.remove(i);
 i--;
 }
 }
 }
 // 把别名替换成真实的接收器 
 if (newReceivers != null) {
 for (int i = newReceivers.size() - 1; i >= 0; i--) {
 final ResolveInfo ri = newReceivers.get(i);
 final Resolution<ResolveInfo> resolution =
 mComponentAliasResolver.resolveReceiver(intent, ri, resolvedType,
 pmFlags, user, callingUid, true /* forSend */);
 if (resolution == null) {
 // 未找到对应的接收器,删除这个记录 
 newReceivers.remove(i);
 continue;
 }
 if (resolution.isAlias()) {
 //找到对应的真实的接收器,就把别名的记录替换成真实的目标
 newReceivers.set(i, resolution.getTarget());
 }
 }
 }
 if (newReceivers != null && newReceivers.size() == 0) {
 newReceivers = null;
 }

 if (receivers == null) {
 receivers = newReceivers;
 } else if (newReceivers != null) {
 if (!scannedFirstReceivers) {
 //查找单用户记录的接收器,并且保存
 scannedFirstReceivers = true;
 for (int i = 0; i < receivers.size(); i++) {
 ResolveInfo ri = receivers.get(i);
 if ((ri.activityInfo.flags&ActivityInfo.FLAG_SINGLE_USER) != 0) {
 ComponentName cn = new ComponentName(
 ri.activityInfo.packageName, ri.activityInfo.name);
 if (singleUserReceivers == null) {
 singleUserReceivers = new HashSet<ComponentName>();
 }
 singleUserReceivers.add(cn);
 }
 }
 }
 for (int i = 0; i < newReceivers.size(); i++) {
 ResolveInfo ri = newReceivers.get(i);
 if ((ri.activityInfo.flags & ActivityInfo.FLAG_SINGLE_USER) != 0) {
 ComponentName cn = new ComponentName(
 ri.activityInfo.packageName, ri.activityInfo.name);
 if (singleUserReceivers == null) {
 singleUserReceivers = new HashSet<ComponentName>();
 }
 if (!singleUserReceivers.contains(cn)) {
 //对于单用户的接收器,只存一次到返回结果中
 singleUserReceivers.add(cn);
 receivers.add(ri);
 }
 } else {
 receivers.add(ri);
 }
 }
 }
 }
 ...
 return receivers;
}

以上就根据信息通过PMS获取所有通过Manifest静态注册的广播接收器,对其有一些处理,详见上面的注释。

对于我们在代码中动态注册的接收器,则需要看mReceiverResolver.queryIntent的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
protected final List<R> queryIntent(@NonNull PackageDataSnapshot snapshot, Intent intent,
 String resolvedType, boolean defaultOnly, @UserIdInt int userId, long customFlags) {
 ArrayList<R> finalList = new ArrayList<R>();
 F[] firstTypeCut = null;
 F[] secondTypeCut = null;
 F[] thirdTypeCut = null;
 F[] schemeCut = null;

 if (resolvedType == null && scheme == null && intent.getAction() != null) {
 firstTypeCut = mActionToFilter.get(intent.getAction());
 }

 FastImmutableArraySet<String> categories = getFastIntentCategories(intent);
 Computer computer = (Computer) snapshot;
 if (firstTypeCut != null) {
 buildResolveList(computer, intent, categories, debug, defaultOnly, resolvedType,
 scheme, firstTypeCut, finalList, userId, customFlags);
 }
 sortResults(finalList); //按照IntentFilter的priority优先级降序排序
 return finalList;
}

以上代码中,这个mActionToFilter就是我们前面注册广播时候,将BroadcastFilter添加进去的一个ArrayMap,这里会根据Action去其中取出所有的BroadcastFilter,之后调用buildResolveList将其中的不符合本次广播接收要求的广播接收器给过滤掉,最后按照IntentFilter的优先级降序排列。

到这里我们就有两个列表receivers存放Manifest静态注册的将要本次广播接收者,和registeredReceivers通过代码手动注册的广播接收者。

广播入队列

首先来看通过代码注册的接收器不为空,并且不是有序广播的情况,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
int NR = registeredReceivers != null ? registeredReceivers.size() : 0;
if (!ordered && NR > 0) {
 ...
 final BroadcastQueue queue = broadcastQueueForIntent(intent);
 BroadcastRecord r = new BroadcastRecord(queue, intent, callerApp, callerPackage,
 callerFeatureId, callingPid, callingUid, callerInstantApp, resolvedType,
 requiredPermissions, excludedPermissions, excludedPackages, appOp, brOptions,
 registeredReceivers, resultTo, resultCode, resultData, resultExtras, ordered,
 sticky, false, userId, allowBackgroundActivityStarts,
 backgroundActivityStartsToken, timeoutExempt);
 ...
 final boolean replaced = replacePending
 && (queue.replaceParallelBroadcastLocked(r) != null);
 if (!replaced) {
 queue.enqueueParallelBroadcastLocked(r);
 queue.scheduleBroadcastsLocked();
 }
 registeredReceivers = null;
 NR = 0;
}

在这里,第4行会首先根据intent的flag获取对应的BroadcastQueue,这里有四个Queue,不看其代码了,不过逻辑如下:

  1. 如果有FLAG_RECEIVER_OFFLOAD_FOREGROUND 标记,则使用mFgOffloadBroadcastQueue
  2. 如果当前开启了offloadQueue,也就是mEnableOffloadQueue,并且有FLAG_RECEIVER_OFFLOAD标记,则使用mBgOffloadBroadcastQueue
  3. 如果有FLAG_RECEIVER_FOREGROUND,也就是前台时候才接收广播,则使用mFgBroadcastQueue
  4. 如果没有上述标记,则使用mBgBroadcastQueue。 拿到queue之后,会创建一条BroadcastRecord,其中会记录传入的参数,intent,以及接收的registeredReceivers,调用queue的入队方法,最后把registeredReceivers设置为null,计数也清零。具体入队的代码,我们随后再看,这里先看其他情况下的广播入队代码。
 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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
int ir = 0;
if (receivers != null) {
 String skipPackages[] = null;
 //对于添加应用,删除应用数据之类的广播,不希望变化的应用能够接收到对应的广播
 //这里设置忽略它们
 if (Intent.ACTION_PACKAGE_ADDED.equals(intent.getAction())
 || Intent.ACTION_PACKAGE_RESTARTED.equals(intent.getAction())
 || Intent.ACTION_PACKAGE_DATA_CLEARED.equals(intent.getAction())) {
 Uri data = intent.getData();
 if (data != null) {
 String pkgName = data.getSchemeSpecificPart();
 if (pkgName != null) {
 skipPackages = new String[] { pkgName };
 }
 }
 } else if (Intent.ACTION_EXTERNAL_APPLICATIONS_AVAILABLE.equals(intent.getAction())) {
 skipPackages = intent.getStringArrayExtra(Intent.EXTRA_CHANGED_PACKAGE_LIST);
 }
 if (skipPackages != null && (skipPackages.length > 0)) {
 //如果Manifest注册的广播接收器的包名和skip的一样,那就移除它们
 for (String skipPackage : skipPackages) {
 if (skipPackage != null) {
 int NT = receivers.size();
 for (int it=0; it<NT; it++) {
 ResolveInfo curt = (ResolveInfo)receivers.get(it);
 if (curt.activityInfo.packageName.equals(skipPackage)) {
 receivers.remove(it);
 it--;
 NT--;
 }
 }
 }
 }
 }

 int NT = receivers != null ? receivers.size() : 0;
 int it = 0;
 ResolveInfo curt = null;
 BroadcastFilter curr = null;
 while (it < NT && ir < NR) {
 if (curt == null) {
 curt = (ResolveInfo)receivers.get(it);
 }
 if (curr == null) {
 curr = registeredReceivers.get(ir);
 }
 if (curr.getPriority() >= curt.priority) {
 //如果动态注册的广播优先级比静态注册的等级高,就把它添加到静态注册的前面。
 receivers.add(it, curr);
 ir++;
 curr = null;
 it++;
 NT++;
 } else {
 // 如果动态注册的广播优先级没有静态注册的等级高,那就移动静态注册的游标,下一轮在执行相关的判断。
 it++;
 curt = null;
 }
 }
}
while (ir < NR) { //如果registeredReceivers中的元素没有全部放到receivers里面,就一个一个的遍历并放进去。
 if (receivers == null) {
 receivers = new ArrayList();
 }
 receivers.add(registeredReceivers.get(ir));
 ir++;
}

以上的代码所做的事情就是首先移除静态注册的广播当中需要忽略的广播接收器,随后将静态注册和动态注册的广播接收器,按照优先级合并到同一个列表当中,当然如果动态注册的前面已经入队过了,这里实际上是不会在合并的。关于合并的代码,就是经典的两列表合并的算法,具体请看代码和注释。

 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
if ((receivers != null && receivers.size() > 0)
 || resultTo != null) {
 BroadcastQueue queue = broadcastQueueForIntent(intent);
 BroadcastRecord r = new BroadcastRecord(queue, intent, callerApp, callerPackage,
 callerFeatureId, callingPid, callingUid, callerInstantApp, resolvedType,
 requiredPermissions, excludedPermissions, excludedPackages, appOp, brOptions,
 receivers, resultTo, resultCode, resultData, resultExtras,
 ordered, sticky, false, userId, allowBackgroundActivityStarts,
 backgroundActivityStartsToken, timeoutExempt);

 final BroadcastRecord oldRecord =
 replacePending ? queue.replaceOrderedBroadcastLocked(r) : null;
 if (oldRecord != null) {
 if (oldRecord.resultTo != null) {
 final BroadcastQueue oldQueue = broadcastQueueForIntent(oldRecord.intent);
 try {
 oldRecord.mIsReceiverAppRunning = true;
 oldQueue.performReceiveLocked(oldRecord.callerApp, oldRecord.resultTo,
 oldRecord.intent,
 Activity.RESULT_CANCELED, null, null,
 false, false, oldRecord.userId, oldRecord.callingUid, callingUid,
 SystemClock.uptimeMillis() - oldRecord.enqueueTime, 0);
 } catch (RemoteException e) {

 }
 }
 } else {
 queue.enqueueOrderedBroadcastLocked(r);
 queue.scheduleBroadcastsLocked();
 }
}else {
 //对于无人关心的广播,也做一下记录
 if (intent.getComponent() == null && intent.getPackage() == null
 && (intent.getFlags()&Intent.FLAG_RECEIVER_REGISTERED_ONLY) == 0) {
 addBroadcastStatLocked(intent.getAction(), callerPackage, 0, 0, 0);
 }
}

以上的代码,跟前面入队的代码也差不多,不过这里如果采用的方法是enqueueOrderedBroadcastLocked,并且多了关于已经发送的广播的替换的逻辑,这里我们先不关注。如果receivers为空,并且符合条件的隐式广播,系统也会对其进行记录,具体,我们这里也不进行分析了。

BroadcastQueue 入队

我们知道前面入队的时候有两个方法,分别是enqueueParallelBroadcastLockedenqueueOrderedBroadcastLocked,我们先来分析前者。

1
2
3
4
5
6
7
public void enqueueParallelBroadcastLocked(BroadcastRecord r) {
 r.enqueueClockTime = System.currentTimeMillis();
 r.enqueueTime = SystemClock.uptimeMillis();
 r.enqueueRealTime = SystemClock.elapsedRealtime();
 mParallelBroadcasts.add(r);
 enqueueBroadcastHelper(r);
}

这里就是将BroadcastRecord放到mParallelBroadcasts列表中,随后执行enqueueBroadcastHelper,我们先看继续看一下enqueueOrderedBroadcastLocked方法。

1
2
3
4
5
6
7
public void enqueueOrderedBroadcastLocked(BroadcastRecord r) {
 r.enqueueClockTime = System.currentTimeMillis();
 r.enqueueTime = SystemClock.uptimeMillis();
 r.enqueueRealTime = SystemClock.elapsedRealtime();
 mDispatcher.enqueueOrderedBroadcastLocked(r);
 enqueueBroadcastHelper(r);
}

这里跟上面很类似,差别是这里把BroadcastRecord入队了mDispatcher,对于普通广播,其内部是把这个记录放到了mOrderedBroadcasts列表。 而enqueueBroadcastHelper方法仅仅用于trace,我们这里不需要关注。

到了这里,我们把广播放到对应的列表了,但是广播还是没有分发出去。

AMS端广播的分发

以上是代码入了BroadcastQueu,接下来就可以看看队列中如何处理它了。首先需要注意一下,记录在入队的同时还调用了BroadcastQueuescheduleBroadcastsLock方法,代码如下:

1
2
3
4
5
6
7
public void scheduleBroadcastsLocked() {
 if (mBroadcastsScheduled) {
 return;
 }
 mHandler.sendMessage(mHandler.obtainMessage(BROADCAST_INTENT_MSG, this));
 mBroadcastsScheduled = true;
}

这里使用了Handler发送了一条BROADCAST_INTENT_MSG消息,我们可以去看一下BroadcastHandlerhandleMessage方法。其中在处理这个消息的时候调用了processNextBroadcast方法,我们可以直接去看其实现:

1
2
3
4
5
private void processNextBroadcast(boolean fromMsg) {
 synchronized (mService) {
 processNextBroadcastLocked(fromMsg, false);
 }
}

这里开启了同步块调用了processNextBroadcastLocked方法,这个方法依然很长,其中涉及到广播的权限判断,对于静态注册的广播,可能还涉及到对应进程的启动等。

动态广播的分发

动态注册的无序广播相对比较简单,这里我们仅仅看一下其中无序广播的分发处理:

 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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
if (fromMsg) {
 mBroadcastsScheduled = false; //通过handleMessage过来,把flag设置为false
}
while (mParallelBroadcasts.size() > 0) {
 r = mParallelBroadcasts.remove(0);
 r.dispatchTime = SystemClock.uptimeMillis();
 r.dispatchRealTime = SystemClock.elapsedRealtime();
 r.dispatchClockTime = System.currentTimeMillis();
 r.mIsReceiverAppRunning = true;
 final int N = r.receivers.size();

 for (int i=0; i<N; i++) {
 Object target = r.receivers.get(i);

 deliverToRegisteredReceiverLocked(r,
 (BroadcastFilter) target, false, i); //分发
 }
 addBroadcastToHistoryLocked(r); //把广播添加的历史记录中
}


这里就是遍历`ParallelBroadcasts`中的每一条`BroadcastRecord`,其中会再分别遍历每一个`BroadcastFilter`,调用`deliverToRegisteredReceiverLocked`来分发广播
```java
private void deliverToRegisteredReceiverLocked(BroadcastRecord r,
 BroadcastFilter filter, boolean ordered, int index) {
 boolean skip = false;
 ...

 if (filter.requiredPermission != null) {
 int perm = mService.checkComponentPermission(filter.requiredPermission,
 r.callingPid, r.callingUid, -1, true);
 if (perm != PackageManager.PERMISSION_GRANTED) {
 skip = true;
 } else {
 final int opCode = AppOpsManager.permissionToOpCode(filter.requiredPermission);
 if (opCode != AppOpsManager.OP_NONE
 && mService.getAppOpsManager().noteOpNoThrow(opCode, r.callingUid,
 r.callerPackage, r.callerFeatureId, "Broadcast sent to protected receiver")
 != AppOpsManager.MODE_ALLOWED) {
 skip = true;
 }
 }
 }
 ...
 if (skip) {
 r.delivery[index] = BroadcastRecord.DELIVERY_SKIPPED;
 return;
 }

 r.delivery[index] = BroadcastRecord.DELIVERY_DELIVERED;
 ...
 try {

 if (filter.receiverList.app != null && filter.receiverList.app.isInFullBackup()) {
 if (ordered) {
 skipReceiverLocked(r);
 }
 } else {
 r.receiverTime = SystemClock.uptimeMillis();
 maybeAddAllowBackgroundActivityStartsToken(filter.receiverList.app, r);
 maybeScheduleTempAllowlistLocked(filter.owningUid, r, r.options);
 maybeReportBroadcastDispatchedEventLocked(r, filter.owningUid);
 performReceiveLocked(filter.receiverList.app, filter.receiverList.receiver,
 new Intent(r.intent), r.resultCode, r.resultData,
 r.resultExtras, r.ordered, r.initialSticky, r.userId,
 filter.receiverList.uid, r.callingUid,
 r.dispatchTime - r.enqueueTime,
 r.receiverTime - r.dispatchTime);
 if (filter.receiverList.app != null
 && r.allowBackgroundActivityStarts && !r.ordered) {
 postActivityStartTokenRemoval(filter.receiverList.app, r);
 }
 }
 if (ordered) {
 r.state = BroadcastRecord.CALL_DONE_RECEIVE;
 }
 } catch (RemoteException e) {
 ...
 if (ordered) {
 r.receiver = null;
 r.curFilter = null;
 filter.receiverList.curBroadcast = null;
 }
 }
}

在这个方法中有大段的代码是判断是否需要跳过当前这个广播,我这里仅仅保留了几句权限检查的代码。对于跳过的记录会将其BroadcastRecorddelivery[index]值设置为DELIVERY_SKIPPED, 而成功分发的会设置为DELIVERY_DELIVERED。对于有序广播的分发我们这里也不予分析,直接看无序广播的分发,在分发之前会尝试给对应的接收进程添加后台启动Activity的权限,这个会在分发完成之后恢复原状,调用的是maybeAddAllowBackgroundActivityStartsToken,就不具体分析了。

之后会调用performReceiveLocked去进行真正的分发,代码如下:

 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
void performReceiveLocked(ProcessRecord app, IIntentReceiver receiver,
 Intent intent, int resultCode, String data, Bundle extras,
 boolean ordered, boolean sticky, int sendingUser,
 int receiverUid, int callingUid, long dispatchDelay,
 long receiveDelay) throws RemoteException {
 if (app != null) {
 final IApplicationThread thread = app.getThread();
 if (thread != null) {
 try {
 thread.scheduleRegisteredReceiver(receiver, intent, resultCode,
 data, extras, ordered, sticky, sendingUser,
 app.mState.getReportedProcState());
 } catch (RemoteException ex) {
 ...
 throw ex;
 }
 } else {
 ...
 throw new RemoteException("app.thread must not be null");
 }
 } else {
 receiver.performReceive(intent, resultCode, data, extras, ordered,
 sticky, sendingUser);
 }
 ...
}

在执行分发的代码中,如果我们的ProcessRecord不为空,并且ApplicationThread也存在的情况下,会调用它的scheduleRegisterReceiver方法。如果进程记录为空,则会直接使用IIntentReceiverperformReceiver方法。我们在App中动态注册的情况,ProcessRecord一定是不为空的,我们也以这种情况继续向下分析。

动态注册广播分发App进程逻辑

1
2
3
4
5
6
7
public void scheduleRegisteredReceiver(IIntentReceiver receiver, Intent intent,
 int resultCode, String dataStr, Bundle extras, boolean ordered,
 boolean sticky, int sendingUser, int processState) throws RemoteException {
 updateProcessState(processState, false);
 receiver.performReceive(intent, resultCode, dataStr, extras, ordered,
 sticky, sendingUser);
}

在应用进程中,首先也只是根据AMS传过来的processState更新一下进程的状态,随后还是调用了IIntentReceiverperformReceive方法,performReceiveLoadedApk当中,为内部类InnerReceiver的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void performReceive(Intent intent, int resultCode, String data,
 Bundle extras, boolean ordered, boolean sticky, int sendingUser) {
 final LoadedApk.ReceiverDispatcher rd;
 if (intent == null) {
 rd = null;
 } else {
 rd = mDispatcher.get(); //获取ReceiverDispatcher
 }
 if (rd != null) {
 rd.performReceive(intent, resultCode, data, extras,
 ordered, sticky, sendingUser);
 } else {
 IActivityManager mgr = ActivityManager.getService();
 try {
 if (extras != null) {
 extras.setAllowFds(false);
 }
 mgr.finishReceiver(this, resultCode, data, extras, false, intent.getFlags());
 } catch (RemoteException e) {
 throw e.rethrowFromSystemServer();
 }
 }
}

在应用进程中,首先会获取ReceiverDisptcher,这个一般不会为空。但是系统代码比较严谨,也考虑了,不存在的情况会调用AMS的finishReceiver完成整个流程。

对于存在的情况,会调用ReceiverDispatcherperformReceive方法继续分发。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public void performReceive(Intent intent, int resultCode, String data,
 Bundle extras, boolean ordered, boolean sticky, int sendingUser) {
 final Args args = new Args(intent, resultCode, data, extras, ordered,
 sticky, sendingUser);
 ..
 if (intent == null || !mActivityThread.post(args.getRunnable())) {
 if (mRegistered && ordered) {
 IActivityManager mgr = ActivityManager.getService();
 ..
 args.sendFinished(mgr);
 }
 }
}

这里的代码有点绕,不过也还比较清晰,首先是创建了一个Args对象,之后根据java的语法,如果intent不为空的时候会执行如下代码:

1
mActivityThread.post(args.getRunnable())

当这个执行失败的时候,才会看情况执行8行到第10行的代码。而这个Runnable就是应用端真正分发的逻辑,其代码如下:

 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
public final Runnable getRunnable() {
 return () -> {
 final BroadcastReceiver receiver = mReceiver;
 final boolean ordered = mOrdered;


 final IActivityManager mgr = ActivityManager.getService();
 final Intent intent = mCurIntent;

 mCurIntent = null;
 mDispatched = true;
 mRunCalled = true;
 if (receiver == null || intent == null || mForgotten) {
 ...
 return;
 }
 try {
 ClassLoader cl = mReceiver.getClass().getClassLoader();
 intent.setExtrasClassLoader(cl);
 intent.prepareToEnterProcess(ActivityThread.isProtectedBroadcast(intent),
 mContext.getAttributionSource());
 setExtrasClassLoader(cl);
 receiver.setPendingResult(this);
 receiver.onReceive(mContext, intent);
 } catch (Exception e) {
 if (mRegistered && ordered) {
 sendFinished(mgr);
 }
 if (mInstrumentation == null ||
 !mInstrumentation.onException(mReceiver, e)) {
 throw new RuntimeException(
 "Error receiving broadcast " + intent
 + " in " + mReceiver, e);
 }
 }

 if (receiver.getPendingResult() != null) {
 finish();
 }
 };
}

这里的receiver就是我们注册时候的那个BroadcastReceiver,这里将当前的Args对象作为它的PendingResult,在这里调用了它的onReceive方法 ,最后看pendingResult是否为空,不为空则调用PendingResultfinish()方法。当我们在onReceive中编写代码的时候,如果调用了goAsync的话,那这里的PendingResult就会为空。

另外就是我们这个Runnable是使用的mActivityThread的post方法投递出去的,它是一个Handler对象,它是在注册广播接收器的时候指定的,默认是应用的主线程Handler,也就是说广播的执行会在主线程。

但是即使是我们使用goAsync的话,处理完成之后也是需要手动调用finish的,我们后面在来看相关的逻辑。

静态广播的发送

在前面分析的BroadcastQueueprocessNextBroadcastLocked方法中,我们只分析了动态广播的发送,这里再看一下静态广播的发送,首先仍然是看processNextBroadcastLocked中的相关源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BroadcastRecord r;
do {
 r = mDispatcher.getNextBroadcastLocked(now);
 if (r == null) {
 ...
 return;
 }
 ...

} while(r === null);
...
if (app != null && app.getThread() != null && !app.isKilled()) {
 try {
 app.addPackage(info.activityInfo.packageName,
 info.activityInfo.applicationInfo.longVersionCode, mService.mProcessStats);
 maybeAddAllowBackgroundActivityStartsToken(app, r);
 r.mIsReceiverAppRunning = true;
 processCurBroadcastLocked(r, app);
 return;
 } catch(RemoteException e) {
 ...
 }
}
...

在第3行,会从mDispatcher中拿BroadcastRecord的记录,我们之前在AMS端入队的代码,对于静态注册的广播和有序广播都是放在mDispatcher当中的,这里拿到动态注册的有序广播也会从这里拿,它的后续逻辑跟前面分析的是一样的,这里不再看了。对于静态注册的广播,在调用后续的方法之前,需要先获取对应进程的ProcessRecord,和ApplicationThread,并且进行广播权限的检查,进程是否存活检查这些在我们11行的位置,都省略不看了。如果App进程存活则会走到我们12行的部分,否则会去创建对应的进程,创建完进程会再去分发广播。

动态注册的广播,会传一个IIntentReceiver的Binder到AMS,而静态注册的广播,我们跟着第18行代码processCurBroadcastLocked方法进去一览究竟:

 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
private final void processCurBroadcastLocked(BroadcastRecord r,
 ProcessRecord app) throws RemoteException {
 final IApplicationThread thread = app.getThread();
 ...
 r.receiver = thread.asBinder();
 r.curApp = app;
 final ProcessReceiverRecord prr = app.mReceivers;
 prr.addCurReceiver(r);
 app.mState.forceProcessStateUpTo(ActivityManager.PROCESS_STATE_RECEIVER);
 ...
 r.intent.setComponent(r.curComponent);

 boolean started = false;
 try {
 mService.notifyPackageUse(r.intent.getComponent().getPackageName(),
 PackageManager.NOTIFY_PACKAGE_USE_BROADCAST_RECEIVER);
 thread.scheduleReceiver(new Intent(r.intent), r.curReceiver,
 mService.compatibilityInfoForPackage(r.curReceiver.applicationInfo),
 r.resultCode, r.resultData, r.resultExtras, r.ordered, r.userId,
 app.mState.getReportedProcState());
 started = true;
 } finally {
 if (!started) {
 r.receiver = null;
 r.curApp = null;
 prr.removeCurReceiver(r);
 }
 }

}

在这个方法中,把App的ProcessRecord放到了BroadcastRecord当中,并且把ApplicationThread设置为receiver,最后是调用了ApplicationThreadscheduleReceiver,从而通过binder调用App进程。

静态注册广播分发App进程逻辑

通过Binder调用,在App的ApplicationThread代码中,调用的是如下方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public final void scheduleReceiver(Intent intent, ActivityInfo info,
 CompatibilityInfo compatInfo, int resultCode, String data, Bundle extras,
 boolean sync, int sendingUser, int processState) {
 updateProcessState(processState, false);
 ReceiverData r = new ReceiverData(intent, resultCode, data, extras,
 sync, false, mAppThread.asBinder(), sendingUser);
 r.info = info;
 r.compatInfo = compatInfo;
 sendMessage(H.RECEIVER, r);
}

这里是创建了一个ReceiverData把AMS传过来数据包裹其中,并且通过消息发出去,之后会调用ActivityThreadhandleReceiver方法, 代码如下:

 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
49
50
private void handleReceiver(ReceiverData data) {
 String component = data.intent.getComponent().getClassName();

 LoadedApk packageInfo = getPackageInfoNoCheck(
 data.info.applicationInfo, data.compatInfo);

 IActivityManager mgr = ActivityManager.getService();

 Application app;
 BroadcastReceiver receiver;
 ContextImpl context;
 try {
 app = packageInfo.makeApplicationInner(false, mInstrumentation);
 context = (ContextImpl) app.getBaseContext();
 if (data.info.splitName != null) {
 context = (ContextImpl) context.createContextForSplit(data.info.splitName);
 }
 if (data.info.attributionTags != null && data.info.attributionTags.length > 0) {
 final String attributionTag = data.info.attributionTags[0];
 context = (ContextImpl) context.createAttributionContext(attributionTag);
 }
 java.lang.ClassLoader cl = context.getClassLoader();
 data.intent.setExtrasClassLoader(cl);
 data.intent.prepareToEnterProcess(
 isProtectedComponent(data.info) || isProtectedBroadcast(data.intent),
 context.getAttributionSource());
 data.setExtrasClassLoader(cl);
 receiver = packageInfo.getAppFactory()
 .instantiateReceiver(cl, data.info.name, data.intent);
 } catch (Exception e) {
 data.sendFinished(mgr);
 ...
 }

 try {

 sCurrentBroadcastIntent.set(data.intent);
 receiver.setPendingResult(data);
 receiver.onReceive(context.getReceiverRestrictedContext(),
 data.intent);
 } catch (Exception e) {
 data.sendFinished(mgr);
 } finally {
 sCurrentBroadcastIntent.set(null);
 }

 if (receiver.getPendingResult() != null) {
 data.finish();
 }
}

这个代码中主要有两个try-catch的代码块,分别是两个主要的功能区。因为静态注册的广播,我们的广播接收器是没有构建的,AMS传过来的只是广播的类名,因此,第一块代码的功能就是创建广播接收器对象。第二块代码则是去调用广播接收器的onReceive方法,从而传递广播。另外这里会调用PendingResultfinish去执行广播处理完成之后的逻辑,以及告知AMS,不过这里的PendingResult就是前面创建的ReceiverData

完成广播的发送

在分析前面的动态注册广播分发和静态注册广播分发的时候,最终在App进程它们都有一个Data,静态为ReceiverData, 动态为Args,他们都继承了PendingResult,最终都会调用PendingResultfinish方法来完成后面的收尾工作,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public final void finish() {
 if (mType == TYPE_COMPONENT) {
 final IActivityManager mgr = ActivityManager.getService();
 if (QueuedWork.hasPendingWork()) {
 QueuedWork.queue(new Runnable() {
 @Override public void run() {
 sendFinished(mgr);
 }
 }, false);
 } else {
 sendFinished(mgr);
 }
 } else if (mOrderedHint && mType != TYPE_UNREGISTERED) {
 final IActivityManager mgr = ActivityManager.getService();
 sendFinished(mgr);
 }
}

这里的QueuedWork主要用于运行SharedPreferences写入数据到磁盘,当然这个如果其中有未运行的task则会添加一个Task到其中来运行sendFinished,这样做的目的是为了保证如果当前除了广播接收器没有别的界面或者Service运行的时候,AMS不会杀掉当前的进程。否则会直接运行sendFinished方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public void sendFinished(IActivityManager am) {
 synchronized (this) {
 if (mFinished) {
 throw new IllegalStateException("Broadcast already finished");
 }
 mFinished = true;
 try {
 if (mResultExtras != null) {
 mResultExtras.setAllowFds(false);
 }
 if (mOrderedHint) {
 am.finishReceiver(mToken, mResultCode, mResultData, mResultExtras,
 mAbortBroadcast, mFlags);
 } else {
 am.finishReceiver(mToken, 0, null, null, false, mFlags);
 }
 } catch (RemoteException ex) {
 }
 }
}

这里就是调用AMS的finishReceiver方法,来告诉AMS广播接收的处理已经执行完了。

 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
public void finishReceiver(IBinder who, int resultCode, String resultData,
 Bundle resultExtras, boolean resultAbort, int flags) {
 if (resultExtras != null && resultExtras.hasFileDescriptors()) {
 throw new IllegalArgumentException("File descriptors passed in Bundle");
 }

 final long origId = Binder.clearCallingIdentity();
 try {
 boolean doNext = false;
 BroadcastRecord r;
 BroadcastQueue queue;

 synchronized(this) {
 if (isOnFgOffloadQueue(flags)) {
 queue = mFgOffloadBroadcastQueue;
 } else if (isOnBgOffloadQueue(flags)) {
 queue = mBgOffloadBroadcastQueue;
 } else {
 queue = (flags & Intent.FLAG_RECEIVER_FOREGROUND) != 0
 ? mFgBroadcastQueue : mBgBroadcastQueue;
 }

 r = queue.getMatchingOrderedReceiver(who);
 if (r != null) {
 doNext = r.queue.finishReceiverLocked(r, resultCode,
 resultData, resultExtras, resultAbort, true);
 }
 if (doNext) {
 }
 trimApplicationsLocked(false, OomAdjuster.OOM_ADJ_REASON_FINISH_RECEIVER);
 }

 } finally {
 Binder.restoreCallingIdentity(origId);
 }
}

相关的逻辑从13行开始,首先仍然是根据广播的flag找到之前的BroadcastQueue,之后根据IBinder找到发送的这一条BroadcastRecord,调用Queue的finishReceiverLocked方法。根据它的返回值,再去处理队列中的下一个广播记录。最后的trimApplicationsLocked里面会视情况来决定是否停止App进程,我们这里就不进行分析了。

processNextBroadcastLocaked前面已经分析过了,这里只需要来看finishReceiverLocked方法,代码如下:

 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
public boolean finishReceiverLocked(BroadcastRecord r, int resultCode,
 String resultData, Bundle resultExtras, boolean resultAbort, boolean waitForServices) {
 final int state = r.state;
 final ActivityInfo receiver = r.curReceiver;
 final long finishTime = SystemClock.uptimeMillis();
 final long elapsed = finishTime - r.receiverTime;
 r.state = BroadcastRecord.IDLE;
 final int curIndex = r.nextReceiver - 1;
 if (curIndex >= 0 && curIndex < r.receivers.size() && r.curApp != null) {
 final Object curReceiver = r.receivers.get(curIndex);

 }
 ...

 r.receiver = null;
 r.intent.setComponent(null);
 if (r.curApp != null && r.curApp.mReceivers.hasCurReceiver(r)) {
 r.curApp.mReceivers.removeCurReceiver(r);
 mService.enqueueOomAdjTargetLocked(r.curApp);
 }
 if (r.curFilter != null) {
 r.curFilter.receiverList.curBroadcast = null;
 }
 r.curFilter = null;
 r.curReceiver = null;
 r.curApp = null;
 mPendingBroadcast = null;

 r.resultCode = resultCode;
 r.resultData = resultData;
 r.resultExtras = resultExtras;
 ....
 r.curComponent = null;

 return state == BroadcastRecord.APP_RECEIVE
 || state == BroadcastRecord.CALL_DONE_RECEIVE;
}

在这里,我们最关注的代码就是17行开是的代码,从mReceivers列表中移除BroadcastRecord,并且把ReceiverListcurBroadcast设置为空,并且其他几个参数也设置为空,这样才算完成了广播的分发和处理。

总结

以上就是广播接收器的注册,以及动态、静态广播分发的分析了。关于取消注册是跟注册相关的过程,理解了注册的逻辑,取消注册也可以很快的搞清楚。关于sticky的广播,限于篇幅先不分析了。而有序广播,它在AMS端其实和静态注册的广播是差不多,不过它在调用App进程的时候是有差别的。另外关于权限相关的逻辑,以后在权限代码的分析中可以再进行关注。

看完评论一下吧

更优雅的RSS使用指南

2024年10月14日 21:32

最近因为Follow的爆火,RSS的内容也跟着一起火了一把。笔者最近也优化了一下自己博客的RSS输出,在这里写一下博客如何更加 优雅的输出RSS,以及在订阅RSS的时候如何更好的发现RSS源。

RSS2.0 与 ATOM

RSS是一种消息来源格式,用于方便的将一个站点的内容以一个指定的格式输出,方便订阅者聚合多个站点的内容。

目前RSS的版本为2.0,而我们大家在使用博客输出RSS文件的时候,除了常用的RSS2.0格式,目前还有一个ATOM格式,其目前的版本为1.0。Atom发展的动机为了解决RSS2.0的问题,它解决了如下问题(来源WikiPedia):

  • RSS 2.0可能包含文本或经过编码的HTML内容,同时却没有提供明确的区分办法;相比之下,Atom则提供了明确的标签(也就是typed)。
  • RSS 2.0的description标签可以包含全文或摘要(尽管该标签的英文含义为描述或摘要)。Atom则分别提供了summary和content标签,用以区分摘要和内容,同时Atom允许在summary中添加非文本内容。
  • RSS 2.0存在多种非标准形式的应用,而Atom具有统一的标准,这便于内容的聚合和发现。
  • Atom有符合XML标准的命名空间,RSS 2.0却没有。
  • Atom通过XML内置的xml:base标签来指示相对地址URI,RSS2.0则无相应的机制区分相对地址和绝对地址。
  • Atom通过XML内置的xml:lang,而RSS采用自己的language标签。
  • Atom强制为每个条目设定唯一的ID,这将便于内容的跟踪和更新。
  • Atom 1.0允许条目单独成为文档,RSS 2.0则只支持完整的种子文档,这可能产生不必要的复杂性和带宽消耗。
  • Atom按照RFC3339标准表示时间 ,而RSS2.0中没有指定统一的时间格式。
  • Atom 1.0具有在IANA注册了的MIME类型,而RSS 2.0所使用的application/rss+xml并未注册。
  • Atom 1.0标准包括一个XML schema,RSS 2.0却没有。
  • Atom是IETF组织标准化程序下的一个开放的发展中标准,RSS 2.0则不属于任何标准化组织,而且它不是开放版权的。

相比之下ATOM协议是有更多的有点,如果你RSS生成程序已经支持了Atom那肯定是优先使用Atom。不过现在基本上99%以上的Rss订阅器或者工具对于两者都有很好的支持,因此如果你现在已经使用了RSS2.0也没必要替换成Atom了。

RSS的自动发现

对于提供Rss订阅的网站,最好的方式是提供相应的连接或者使用Rss图标,告诉访客当前网站的Rss地址。

除了这样之外,我们还应该在网站的源码中添加RSS地址,这样对于一些浏览器插件或者订阅软件可以通过我们的网站页面自动发现RSS订阅地址。

对于RSS2.0的订阅地址可以添加如下代码:

1
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />

对于ATOM的订阅地址可以添加如下代码:

1
<link rel="alternate" type="application/atom+xml" href="atom.xml" title="Site title" />

如果你同时提供了ATOM和RSS2.0两种订阅文件,可以上面两行代码都添加。当然现在一些博客程序的模板文件中已经添加了上面的代码,检查一下即可。

RSS输出的优化

因为我的博客是以RSS2.0格式输出的订阅文件,因此这里我就按照我的优化内容来介绍一下输出相关的优化,对于ATtom可以参考其规范文档。

首先区分介绍和全文的输出。对于只输出描述的网站只需要设置描述部分即可,对于输出了全部的博客,还是建议同时输出描述和全文的。 而RSS2.0不支持输出全文,我们可以用一下的标记来输出全文:

1
<content:encoded>全文内容</content:encoded>

其中的文章html,最好做一下转码。 (以上代码加的有问题,有的RSS识别失败,暂时回退了,有时间换Atom)

其次可以补充一下网站的内容的元数据,比如作者的信息,网站的标题简介等等。

对于文章,也可以在输出的时候输出相关的元数据,如标题,作者,标签等。标签支持设置多个,可以用如下的标记:

1
<category domain="{{ .Permalink }}">{{ .LinkTitle }}</category>

另外在我设置的过程,发现rss是提供了一个comments标记的,设置这个标记后,如果RSS阅读器对此支持,理论上可以直接从RSS阅读器点击跳转到文章的评论页面。

最后,我们可能想要检测要多少通过RSS点击跳转到我们博客的访问量,这个时候可以在输出的链接上面加上特定的参数,这样在我们的统计平台上面就可以看到有多少用户从这里打开页面的,我所加的参数如下:

?utm_source=rss

订阅RSS

目前最流行的订阅RSS的方式要属于Follow了,这里也推荐使用。

除了Follow之外,我还自建了一个FreshRss来订阅一些内容,这个的使用要更早于Follow的出现。现在还不能抛弃它的原因是Follow目前不支持移动端,我使用Android的手机,在移动推荐使用FeedMe来浏览FreshRss的订阅内容。

另外,我们在浏览一些内容或者博客的时候,也需要一个工具来帮助我们方便的查看和订阅RSS源,这个时候就要推荐一下DIYgod大佬开发的浏览器插件RSSHub-Radar,对于我们的博客,如果已经加了我前面说的html代码,它可以自己发现订阅地址,如下图所示:

它还支持配置规则,则一些拥有RSSHub订阅的站点,比如b站,微博,小红书等,可以嗅探到RSShub的订阅地址,如下图所示:

另外,看上面弹出的窗口中是可以直接去预览对应的RSS内的,还可以直接跳转到Follow、FreshRss等订阅源去添加这个订阅源,这些可以在插件的设置中进行设置,如下图所示:

除了上面的设置,这个插件还支持一些其他的设置,读者朋友可以自行探索。

总结

以上就是关于网站配置和rss订阅方面我的一些建议,而本文的标题也有一些标题党了,欢迎吐槽。

资料

如果读者需要查阅ATOM和RSS的维基百科,请查看英文版本,中文版本内容比较简略,很多发展相关的内容都没有。

看完评论一下吧

❌