0%

背景

Modern Web Browser 的架构趋势是多进程 Process Per Tab,例如 Chromium(Chrome 或 Edge) 中,每一个 Tab 都对应一个子进程,Safari 虽然不是 Chromium,也是类似的表现。随着 Web Browser 和 Web 技术的发展,越来越多的服务通过 SaaS 或 WebApp 的形式提供,例如 Confluence、Figma 等,Web Browser 逐渐成为日常工作中重度使用的平台和资源占用大户。现在的一些监控服务虽然能获取各个进程的 CPU/Memory Usage,但是无法监测到到底是哪个 Tab 或 WebApp 耗费的资源比较多,无法更细化的分析 Web Browser 性能。因此,更细化的监控 Web Browser 的性能是个痛点与需求。

Chrome 和 Edge 虽然提供了 Task Manager,可以显示其各个子进程的类型,Tab 当前的 URL,以及其 CPU、Memory 等性能数据,但是很多系统后台性能监控服务无法像用户一样直接打开 OS 内置的 Activity Monitor 等软件查看数据,需要自己通过 API 来获取,因此,我们也需要一种能够在浏览器外部获取每个 Tab 的性能数据,并且要知道对应的 URL 是什么,这样才能方便的定位出到底是哪些 Web App 导致 Browser 消耗了大量性能。

Chrome 技术调研

Chrome Extension API: chrome.processes

首先想到了是 Chrome Extension 是否提供了相关 API 可以获取 Tab 的性能数据:
chrome.tabs 能够访问 Chrome 的 Tab 信息,但其 tabId 是 Chrome 内部使用的,与 pid 没有关系

后来看到了 chrome.processes,能够通过 tabId 获取对应 Process 的信息,包括 CPU、Memory、pid 等,但遗憾的是,该 API 只能在 Dev Channel 的 Chrome 下执行,正常用户使用的 Stable Channel 的 Chrome 是无法调用此 API 的。

看到 2017 年 Chrome 论坛也有人问:https://groups.google.com/a/chromium.org/g/chromium-extensions/c/pyAzuN4neHc,于是试试有没有什么 Hack 的方式可以访问 _permission_features.json,从而修改权限,后来发现该文件是 Chrome 编译时候的选项,相当于 Channel 是编译期决议,因此这条路走不通

Chrome DevTools Protocol

Extension 的路走不通,搜索过程中看到了 Chrome DevTools Protocol,是 Chrome 对外开放的一个“远程”调试接口,那通过此接口,能否读取 Chrome 的内部信息呢?读了读文档之后,找到一个 SystemInfo 接口 Chrome DevTools Protocol - SystemInfo domain

但是也不行,原因是:

  1. 使用 Chrome DevTools Protocol 需要在启动 Chrome 时,配置 --remote-debug-port=xxx 的参数,用于启动一个 WebSocket 供外部连接,这对于开放调试或者开发者而言还行,对用户来说太不透明
  2. SystemInfo 中返回的 Process 数据内容太少,虽然有 pid,但是没有 pid 与 Tab 的映射,无法通过 pid 获取 Tab 的信息

逆向 Chrome 内部 IPC 接口

在搜索 “Get Chrome tab pid” 的过程中,看到回答基本都是使用 Chrome 自带的 Task Manager,提供了每个 Tab 对应的 CPU、Memory、Network、pid 等数据

而 Chrome 是多进程架构,进程之间通过 Mojo 进行通信,而 Mojo 比较像 RPC,上层在使用时就如同调用其他进程的 API 一样。而 Chromium 是开源的,能够搜索到 Task Manager 的源码,能否通过模拟 Mojo 的调用,读取 Task Manager 中的数据呢?

首先需要分析一下 Mojo 的数据格式,看到了 Breaking The Browser - A tale of IPC, credentials and backdoors - MDSecAttacking Chrome IPC,并尝试使用文中使用的 Chrome IPC Sniffer 在 Windows 上进行分析:

刚开始但发现 mojo 的解析失效了,Wireshark 无法分析出来最顶层 mojo 的数据包,导致无法看到 mojo 的具体数据结构


后来对比了出问题的 lua 文件和 Github 上对应文件,发现在解压时,sniffer 的 lua 插件中文件内容不全,像是解压问题,再次解压后能够读取数据了。

但 mojo 是 Chrome 内部使用的,不稳定,即使这次能够通过逆向 IPC 接口拿到 Task Manager 数据,可一旦 mojo 协议和 Task Manager 相关接口发生变化,就失效了,而且不确定 IPC 调用时未定义的行为会不会导致 Chrome 崩溃。

慢慢确定思路

到这里仿佛没有路了,在不断搜索中,看到了这个回答:https://stackoverflow.com/questions/63000671/how-would-one-find-the-pid-of-a-browser-extension-running, ps -ax | grep 'Google Chrome Helper' | grep "extension-process" ,是通过分析每个 Chrome 子进程的名字和启动命令行参数得到哪个是 Extension 的,这给我打开了思路,开始仔细分析 ps 出来的 Chrome 子进程的信息:

63554 ?? 0:25.20 /Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/107.0.5304.87/Helpers/Google Chrome Helper (Renderer).app/Contents/MacOS/Google Chrome Helper (Renderer) –type=renderer –display-capture-permissions-policy-allowed –lang=en-US –num-raster-threads=4 –enable-zero-copy –enable-gpu-memory-buffer-compositor-resources –enable-main-frame-before-activation –renderer-client-id=236 –time-ticks-at-unix-epoch=-1666552889280259 –launch-time-ticks=817765184167 –shared-files –field-trial-handle=1718379636,r,13433701468379812598,11346460162339610094,131072 –seatbelt-client=204

分析的规律如下:

  • 对于不同类型的进程,Chrome 启动的程序不一样,可以分析出来 Chrome 子进程的类型
    • Tab 和 Extension 是 Google Chrome Helper (Renderer),type 为 renderer,Extension 会多一个 extension-process 的参数
    • GPU 是 Google Chrome Helper (GPU)
    • Utility 是 Google Chrome Helper,type 为 render,会有一个 utility-sub-type 指定 Utility 的具体功能
    • chrome_crashpad_handler 用于监控 Chrome 的崩溃
  • 对于 Tab,其启动参数中大部分是相同的,有一个比较特殊,叫 --renderer-client-id,我发现这个 id 每个 Tab 都是唯一的

于是想到一种思路:通过 ps 能获取到 pid 与 renderer-client-id 的关系,那能否从 Chrome 中拿到 renderer-client-id 与 Tab 的映射关系呢? 这样的话,Browser 的监控思路就是:

  • 通过一定方式,获取 Tab 与 renderer-client-id 的映射,并将 Tab 对应的 URL 发送给 Native 程序
  • Native 程序在做进程的性能监控时,对于 Chrome,分析 renderer-client-id 与 pid 映射,通过两者结合,将 URL 与进程的性能信息进行绑定

那如何获取 Tab 与 renderer-client-id 的映射?是下一步的攻关难点

解析 Chrome Session File(SNSS)

Chrome 存在一个 Session File SNSS,当 Chrome 被意外关闭时,可以通过访问该文件恢复所有的 Tab。尝试从这里读取 Tab 信息,但问题是:

  1. SNSS 并不是文本文件,需要一个解析器,目前并没有什么稳定的解析器,解析器都是通过逆向写的
  2. 另外,这里只有 URL,没有 Tab 到 pid 的映射

AppleScript 调用 Chrome 接口

使用 AppleScript 调用 Chrome 提供的接口,不行,列出的 Tab 中 tabId 是内部 ID,和 pid 无法映射

基于 chrome://process-internals

Chrome 内置了一些 Scheme 为 chrome 的 URL,通过 chrome://chrome-urls/ 可以查看,其他浏览器见这里,在一个个打开的过程中,发现了 chrome://process-internals,该页面显示所有 Tab 与 Extension 的 FrameTrees:

经过分析,发现 Frame 中第一个数字就是 renderer-client-id,可以通过解析此页面的结果,反查 pid 对应的 URL。但问题是如何在用户无感知的情况下解析获取这个页面的数据呢?

第一个尝试是使用 Chrome 的 headless 模式 ,可以无 UI 的使用 Chrome。但一旦 Chrome 启动,无法再次启动一个 Chrome 进程(注意不是窗口),每次调用 Chrome 的二进制都会触发一个新窗口,而使用 headless 相当于和原来的 Chrome 进程互相隔离,是无法通过访问 headless 的 Chrome 获取用户正在使用的 Chrome 的数据的。

第二个尝试是将 chrome://process-internals 页面的源码 Load 下来,在本地加载,避免解析 HTML 页面。在将相关 JS 代码下载后,在 binding.js 中找不到 Mojo 的定义,经过搜索 Chrome 源码,Mojo 是一个全局变量,无法在本地下载的情况下使用

再次回到 Chrome Extension API: chrome.webNavigation

再次绝望,直到搜到了 Get Chrome tab pid from Chrome extension - Stack Overflow,里面发现 chrome.webNavigation 可以返回当前 Tab 最顶层 Frame(frameId 为 0)的 processId,而经过对比,发现这个 processId 正是 renderer-client-id!
折腾了这么久,终于找到了解决方案:实现一个 Chrome Extension,通过 chrome.tabs 接口遍历所有的 Tab,拿到 tabId 和 URL,再通过 chrome.webNavigation + tabId 获取到 renderer-client-id

确定方案稳定性

这个方案虽然可行,且比较 tricky,但是否稳定?让源码为我们证明!

renderer-client-id

启动参数中的 render-client-id源码),2016 年被加入,赋值给 kRendererClientId

kRendererClientId 在 render-process-host-impl.cc 中使用,AppendRendererCommandLine 将 RenderProcessHostImpl 的 ID 设置为 kRendererClientId(源码):

RenderProcessHostImpl 负责创建和管理渲染子进程,其成员变量 ID 是通过 ChildProcessHostImpl::GenerateChildProcessUniqueId() 生成(源码):

RenderProcessHostImpl 的 Init 会负责启动一个渲染子进程,通过 ChildProcessHost::GetChildPath(flags) 获取 Google Chrome Helper 对应的路径(源码),例如 Render 类型会加 Render 后缀,GPU 类型会加 GPU 后缀

frame.processId

对于 frame 中的 processId,通过源码可以看到,JS binding 调用的是 ProcessInternalsHandler 中的 frame_info->process_id = frame->GetProcess()->GetID();

其中 frame 为 RenderFrameHostImpl,其 GetProcess 返回的是 RenderProcessHost,实际上由 agent_scheduling_group_ 返回

AgentSchedulingGroupHost 在 GetOrCreate 中创建(源码):

AgentSchedulingGroupHost 的 GetOrCreate 被 SiteInstanceGroup 调用(源码):

SiteInstanceGroup 在 SiteInstanceGroupManager 的 GetOrCreateGroupForNewSiteInstance 中调用(源码):

而 GetOrCreateGroupForNewSiteInstance 最终在 SiteInstanceImpl 的 SetProcessInternal 中调用(源码):

而 SetProcessInternal 的 process 参数是 RenderProcessHostImpl 的 GetProcessHostForSiteInstance(源码):

兜兜转转,又回到了 RenderProcessHostImpl,因此数据流就通了:

  1. RenderProcessHostImpl 负责创建一个渲染子进程,通过 GenerateChildProcessUniqueId 生成一个 ID,并配置到其启动程序的 renderer-client-id 上
  2. 同时 FrameInstance 会绑定一个 RenderProcessHostImpl,其 ID 就是 ProcessId

数据流比较稳定:renderer-client-id 从 16 年就有了,一直没变过,frame 的 processId 从 14 年就基于 RenderProcessHostImpl 的 GenerateChildProcessUniqueId 了

Edge

解决了 Chrome,由于 Edge 同样基于 Chromium,因此 Edge 可以直接加载 Chrome 的 Extension,另外,Edge 每个 Tab 的子进程的启动参数中也有 renderer-client-id,所以,Edge 的方案与 Chrome 基本一致:

  • Edge 的子进程前缀是 Microsoft Edge
  • Edge 会有 PrerenderTab,在启动参数中含有 --instant-process

确定了整体方案,还需要验证:

  1. 能否通过代码方式,获取一个 pid 对应进程的命令行启动参数
  2. Extension 如何与 Native App 进行通信

获取 Process 的命令行参数

对于 Windows,参考:How to query a running process for its parameters list? (Windows, C++)c - How can I get the full command line of all the processes after doing a process walk of the snapshot? - Stack Overflow,效果如下:

对于 Mac,参考 Chromium 源码中的 process_info_mac.cc,效果如下:

Extension 同 Native App 通信

搜了一些文章

主流就是两种思路:一个是通过 WebSocket,一个是基于 Chrome 提供的 Native Messaging

  • WebSocket 的问题在于在 Extension 中是否有权限可以访问一个 WebSocket 服务器,且这个服务器是否要求必须是 wss,本地服务器的 ssl 证书不太好弄,另外安全性上风险也高一些
  • Native Messaging 看起来更合适一些,因为需求中数据传输量并不大,而且相对安全性好

Native Messaging 技术调研

在调试 Native Messaging 过程中发现以下问题:

  1. Host 端不能使用任何 cout 输出,否则调试不通。如果要调试,只能输出到 cerr
  2. Host 端是由 Chrome 启动的,自己启动没有用,Chrome 只与自己启动的那一份进程通信
  3. manifest 中 name 必须为 小写字母 . 和 _,其他均为非法
  4. manifest 应该放到 ~/Library/Application Support/Google/Chrome/NativeMessagingHosts 目录
  5. manifest 的名字应该与 name 一致,为 name.json

因此,Native Service 不适合作为 Extension 的 NativeMessagingHost,需要想办法,有两种方式:

  1. Extension 通过 WebSocket 连接到 NativeService
  2. Extension 与 NativeService 实现一个 Proxy,用于 Extension 调用并中转消息

对于 1,问题是:

  • 没有找到一个案例这样用
  • Chrome 本身对 WebSocket 的限制,例如需要使用 wss 而不是 ws
  • NativeService 配置 ssl 证书私钥感觉不安全
  • WebSocket 服务的端口号可能被占用,因此 1 不太合适

对于 2,有以下方案:

  • 使用数据库:Proxy 收到数据后写入数据库,问题是通信是单向的,Ao Zhang 的经验看,在 Windows 上使用数据库不太稳,总有各种各样问题
  • 使用 JSON 文件:Proxy 写入,NativeService 读取,问题是通信是单向的,且读写频率不好同步
  • 使用进程间通信(Windows 是 Named Pipe,Mac 是 Unix Domain Socket):成熟的方案,双向通信,比较稳妥

基于此,架构改变为:增加一个 Extension Helper 作为 Proxy,负责接收 Extension 的数据,通过 IPC 接口转发给 Native Service

参考:

Safari 技术调研

Safari 不是基于 Chromium,而是基于 WebKit 内核:

  1. Safari Web Extension 的 API 与 Chrome 不一样,查看其 API 文档,找到 SFSafariPage 有 getPropertiesWithCompletionHandler(_:) 方法,可以获取 Page Property,但是 SFSafariPageProperties 中只有 title 和 url,没有其他信息
  2. Safari 虽然每个 Tab 也是对应一个进程,名称为 com.apple.WebKit.WebContent,但是并没有什么启动参数

尝试搜了 Safari Web Extension 有没有什么私有 API 可以访问,在 Apple Runtime Headers 中搜了下 macOS/PrivateFrameworks/Safari.framework/BrowserTabPersistentState.h,没有什么可以用的数据

Safari 可以通过 defaults write com.apple.Safari IncludeInternalDebugMenu -bool true 打开 Debug 模式,在选项中将 Tab 对于的 pid 显示在 Tab 标题上。

后来又仔细观察了 Activity Monitor,发现 Safari 对应 Tab 的子进程,能够显示对应 Tab 的 Host URL,于是就想到了:能不能逆向 Activity Monitor,看他到底是怎么实现的。

如何显示 Tab 的 URL

由于 Safari 使用的是 WebKit,每个 Tab 对应是 WebContents,在用 Hopper 加载 Activity Monitor 之后,开始针对 WebContents/WebKit 进行搜索,找到了一个可疑方法,对方法进行反汇编后,定位到一个关键函数调用_LSCopyApplicationInformationItemLSActivePageUserVisibleOriginsKey

用其进行 Google 搜索,找到了以下的参考代码:

在配合 WebKit 源码,可以看到在 WebProcess 设置名称时,会通过 _LSSetApplicationInformationItem 将对应名称设置为 URL。最后,自己写 Demo 验证可行性,也能像 Activity Monitor 一样显示 Tab 对应 pid 的 Host URL 了。

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
const CFStringRef kLSActivePageUserVisibleOriginsKey = CFSTR("LSActivePageUserVisibleOriginsKey");
const CFStringRef kLSDisplayName = CFSTR("LSDisplayName");
const int kLSMagicConstant = -2;

extern CFTypeRef _LSCopyApplicationInformationItem(int /* hopefully */, CFTypeRef, CFStringRef);
extern CFTypeRef _LSASNCreateWithPid(CFAllocatorRef, pid_t);

NSString *getWebKitActiveURL(pid_t pid) {
NSString *url = nil;
CFTypeRef asn = _LSASNCreateWithPid(kCFAllocatorDefault, pid);

if (asn != NULL) {
id information = CFBridgingRelease(_LSCopyApplicationInformationItem(kLSMagicConstant, asn, kLSActivePageUserVisibleOriginsKey));

if ([information isKindOfClass:[NSString class]]) {
url = information;
} else if ([information isKindOfClass:[NSArray class]]) {
NSArray *array = (NSArray *)information;
if (array.count > 0) {
url = array[0];
}
}

CFRelease(asn);
}

return url;
}

如何正确的识别 Safari 的子进程

Safari 使用的 WebKit,每个 Tab 是 com.apple.WebKit.WebContent 子进程,其 ppid 都是 1(launchd),而且如果有其他 App 使用了 WebKit(WKWebView),其子进程也是 com.apple.WebKit.WebContent,使用 ppid 的话无法和 Safari 的 Tab 区分出来。需要使用私有 API responsibility_get_pid_responsible_for_pid 才能取到正确的父进程,参考:terminal - How process hierarchy works in macOS - Ask Different

如何显示 Prewarmed Tab

Safari 有 Prewarmed Tab,此 Tab 对应的进程没有 URL,但是 Activity Monitor 能显示对应进程的名称,通过 _LSCopyApplicationInformationItem(kLSMagicConstant, asn, "LSDisplayName") 获取进程的显示名称。

将现有的 Chrome Extension 转换成 Safari Extension

Converting a web extension for Safari

1
xcrun safari-web-extension-converter --app-name "ArgusSafariHelper" --bundle-identifier "com.argus.safari.helper" --macos-only --copy-resources  ../argus-chromium-extension/extension

Extension 需要处理的有:

  1. 删除不需要的 webNavigation 和处理权限兼容性:Browser compatibility for manifest.json - Mozilla | MDN
  2. 处理 的兼容性:Match patterns in extension manifests - Mozilla | MDN
  3. 使用 browser 而不是 chrome 访问 Extension API

其他 Browser 技术调研

Internet Explorer

c# - How to get the URL of the Internet explorer tabs with PID of each tab? - Stack Overflow

Firefox

Firefox 每个 Tab 对应的子进程有启动参数,也提供了 webNavigation - Mozilla | MDN 的 API,但是返回的数据中并没有 processId(文档上有),不知道为啥,GitHub - mdn/webextensions-examples: Example Firefox add-ons created using the WebExtensions API

不过 Firefox 占有率不高,所以就先不看了。

实现

架构

实现需要 Web Extension + Native Service 配合才能实现,同时需要一个 “Proxy” 作为二者通信的中转站,因此架构主要由三块组成:

  • Web Extension:安装在浏览器中,负责获取 Tab 的 Frame 数据和页面加载性能数据
    • 基于 Chrome Extension API,聚合多个 Event 回调,配合 content.js 中的 PerformanceObserver,生成页面加载性能数据
    • 遍历 Tab 并通过 webNavigation 获取其 Frame 相关数据
    • 通过 Native Messaging 与 Extension Helper 建立其连接,并接收和发送消息
  • Extension Helper:负责消息转发
    • 通过 IPC(Windows 是 Named Pipe,Mac 是 Unix Domain Socket)与 Native Service 建立通信,Native Service 是 Server 端。
    • 通过 stdin 从 Web Extension 接收消息,解码后通过 IPC 转发给 Native Service
    • 通过 IPC 从 Native Service 接收消息,编码后通过 stdout 转发给 Web Extension
  • Native Service:负责分析监控 Browser 各个子进程及其性能数据,聚合 Web Extension 数据后上传
    • 分析各个子进程的启动参数,并通过参数来区分进程类型
    • 监控各个子进程的 CPU 和 Memory 性能
    • 从 Web Extension 接收消息,与进程的性能数据聚合
    • 将页面的 CPU 和 Memory 性能以及页面加载性能上传

最终效果如下,用界面代替“上传”:

实现过程中遇到的坑

Chromium Extension

  • postMessage 发送 JSON 数据时,要使用 { "key": value } 的形式,不能 var object = {}; object.key = value,这样会导致 NativeService 端解析 JSON 数据时失败。
  • 使用 Developer Mode 加载 Extension 时,每次重新加载或者换设备都会导致 Extension 的 UUID 变化,可以利用 manifest.json 中的 key 来锁定 Extension 的 UUID,需要申请一个 Chrome WebStore 账号,将 Extension 的 zip 包上传一次,从而获取 PublicKey。

Extension Helper

  • 在转发数据给 NativeService 时,先发送长度,再发送 JSON 数据。有时候 NativeService 先收到 JSON 数据,导致解析出来的 length 是一个巨大无比的值,在 recv 时越界导致崩溃。后来发现如果长度和数据分两次 send 的话,就会出现这个问题,然后 1 将 NO BLOCK 模式关了,2 将长度和数据合并到一个缓冲区中用一次 send 发送,问题不再出现。
  • 发送数据给 Extension 时,必须用 JSON 包一下,否则 Extension 不识别。
  • Extension Helper 由 Browser 负责启动,属于 Browser 的子进程,其生命周期和 Extension 中 chrome.runtime.connectNative 返回的 Port 一致,最好持有此 Port,不然每次 chrome.runtime.connectNative 都会创建一个 Extension Helper 子进程。

Native Services

  • Unix Domain Socket 会新建一个文件,每次在 bind 前需要用 unlink 删除,不然会导致 bind 失败。
  • 允许非 root 权限程序能够读写 Unix Domain Socket,需要在 bind 前使用 umask,然后在 bind 后恢复。
    1
    2
    int mask = umask(777);
    umask(mask);

背景

猿辅导直播教室最早的业务形态,只有一种教室,在教室内增加各种课堂能力和活动,例如基础的课件渲染、板书笔迹等能力。在这样的业务需求下,整个教室对应一个 View Controller,教室内的每个业务模块使用 Handler(处理业务逻辑) + View(处理模块显示)的模式,教室 View Controller 是 Handler 和 View 的 Delegator,同时也接收直播引擎 SDK 的回调,调用 Handler 进行处理。

随着业务快速迭代,跨越 12 年不同年级和不同学科的教研要求千差万别,开始出现不同类型的新教室,当时因为各种因素为了”快“,实现新教室的方式是:拷贝教室代码后针对该类型教室进行定制。正如茨威格在《断头皇后》中写的,“她那时候还太年轻,不知道所有命运赠送的礼物,早已在暗中标注了价格”,欠下的技术债,使得直播教室的架构越来越影响开发效率和体验:

  1. 教室 View Controller 越来越长,出现多个超过 5 千行以上代码的 View Controller,而且随着教室内课堂交互功能的增加,VC 的大小还会接着增长。
  2. 多个教室大量重复的代码,一个在多教室使用的功能,需要加多次。如果需要修改,也要改多次。
  3. 一个业务功能的代码不够聚合,散落在 View Controller 中多个地方,增删功能时容易遗漏,导致 Bug。
  4. 如果要再新增教室类型,以上问题会越来越严重。

思路

经过对教室 View Controller 和业务模块进行梳理分析,发现:

37CC0313-AEC5-419C-9054-EE019FAF06F3

  • 业务模块 Handler 的 Delegate 都是 View Controller,由 View Controller 来更新 View 或者调度其他 Handler。
  • 同时,View 的创建和层级也维护在 View Controller 中,View 事件 Delegate 给 View Controller 后交由 Handler 或者其他进行处理。
  • 所有引擎 SDK 的消息也都统一回调给了 View Controller,由 View Controller 再传递给 Handler 处理。

由此可以看出,View Controller 由于承载的职责过多,里面充斥了各种胶水代码,是其长度过长的主要原因,同时,由于每个业务的 View 和 Handler 都需要和 View Controller 交互,耦合导致复用性下降,新增教室时只能通过拷贝代码的形式进行。总之,现有架构最核心的问题是:View Controller 的职责太多,业务模块不够内聚,解决了这些,上面的痛点就游刃而解。

解决思路就是:教室积木化

  • 构建教室像搭积木一样,每个业务模块像一块块积木,接口统一可拔插,能够灵活的根据业务要求进行组合,提高构建新教室的效率。
  • View Controller 变成承载积木的容器,以及教室内资源和状态的持有者(因为生命周期一致),代码量和职责会变得很简单,不再有冗长的胶水代码。
  • 业务模块内聚,自管理与其相关的 View、Model、Event,便于集成和拔插。模块间有通信机制和分层,不再通过 View Controller 实现 Delegate 来进行调度。
  • 重构过程是渐进式的,对现有的 Handler 方式改动较小。
  • 能够方便业务写单元测试。

为了更好的体现重构的效果,定下了一个可量化目标:

  1. 教室 View Controller 代码行数降到 500 行以下
  2. 业务模块在多个教室复用时,基本消除重复代码

方案

业务模块 Module

核心点:新引入 Module 的概念,将业务模块的 View 和 Handler 原本在 View Controller 中的胶水代码抽离到 Module 中
41A83409-3074-4E58-9E76-DD134E4DB76C

View Controller 目前会持有各个业务模块的 View 和 Handler,这些 View 和 Handler 的 Delegate 都是 View Controller,是 View Controller 中很大一部分的胶水代码,同时会在多个教室间重复,每次修改都需要在多个教室修改多遍。而引入 Module 后,Module 可以看成一个 Sub View Controller,负责持有 View 和 Handler,处理两者的 Delegate,将多个教室重复的代码整合进来,教室 View Controller 只负责创建并持有业务模块对应的 Module。

Module 作为教室积木化的基本单位,内聚一个业务的所有代码:

  • 管理模块自身的业务逻辑和 View。
  • 监听其关心的直播命令。
  • 通过接口或其他方式进行模块间通信。

实现上,Module 就是一个 Protocol,定义了 Module 的生命周期方法:
5EF6D58C-2226-4406-A400-4EBE0C341E15

模块间通信与依赖注入

核心点:积木有缺口和凸起,模块也有依赖和消息,基于依赖注入 DI,两种类型均通过接口抽象,在模块初始化时根据不同教室需要,注入具体实现

由于模块不再将消息 Delegate 给 View Controller 处理,模块与模块间需要通信,之前通信选用的方式有:直接使用通知、基于 OC Runtime 的 Mediator 方式、通过 Protocol 定义接口 + Register 注册实现的方式等,进过权衡,Protocol 定义接口这种方式更适合积木化重构的业务场景,在 Swift 语言特性的加持下,最终选用了 Resolver 这个 Swift 版本的依赖注入 Dependency Injection 框架,实现模块间通信:

  • 每个 Module 供外部 Module 使用的接口,通过 Protocol 抽象成 Service,并通过 register 机制将 Service 注册。
  • Module 使用其他 Module 的接口时,用 @Inject 的 Property Wrapper 定义一个 Service 类型的属性,通过 Service 调用接口。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 模块 A 定义
protocol Service1 {
func doSomething() {}
}

class ModuleA: Service1 {
func doSomething() {
// doing
}
}

// 模块 B 使用模块 A
class ModuleB {
@Inject var service1: Service1

func handleSomething() {
service1.doSomething()
}
}


// 注册服务
Resolver.register { ModuleA() as Service1 }

依赖注入是一套很成熟的思想,在前后端项目上有广泛应用,积木化使用依赖注入之后:

  • 不仅仅是平级的业务 Module,只要是 Module 需要,直播教室内各项功能都可以抽象成 Service 进行注入。
  • 每个 Module 依赖的是 Service 接口,而不再是具体实现,由 DI 框架 Resolver 负责将真正的 Module 绑定到 Service 中,除了解耦外,在写单元测试时能够方便进行 Mock。

业务模块 View 管理:Layouter

核心点:由于 View Controller 不再直接持有业务 View,因此 View 的层级关系、所处的区域需要从 View Controller 中抽离

一开始的想法是由 Module 来管理,但是 Module 如果作为积木的一块,不应该对自己在教室的什么位置有假设,管理好自己 View 的状态就行,至于放到哪里应该是使用 Module 关心的。但是由 View Controller 管理的话,会导致多个教室间重复,也不够灵活。

328C89B7-F111-4BC5-A34F-EAEFE63F2134

因此, 引入 Layouter 的概念:

  • Layouter 管理 View Controller 的 View,按照 UI 样式划分成多个区域,并创建和排版对应区域的 View
  • Module 将 View 注册到 Layouter 中,Layouter 负责将 View 按照 Identity 和 Priority 安置在期望的区域,并安排好层级关系

实现上 Layouter 和 Module 一样,都是一个 Protocol,不同类型的教室布局实现各自具体的 Layouter,在 layout 方法中进行布局。
6C0CA314-26EB-48C0-8D3C-DDB5E12FBA39

另外,有些区域内的排版在多个 Layouter 中是一样的,在 Layouter 的基础上引入 Area Layouter 的概念,负责一块区域的排版布局,例如课件区,Room Layouter 通过持有 Area Layouter 实现区域布局的复用。

4679D1F3-E016-4D3A-9D6E-DF7E7F5E1BE3

教室状态持有者:Store

核心点:业务模块不再通过 Delegate 拿教室内的通用数据,而是通过 Store 将通用数据传入

各个业务模块经常通过 Delegate 从 View Controller 中获取例如 episodeId、teamId、userId、Episode 之类的数据,而这些数据可以理解是教室内的基础数据或状态,可以将这些数据整合到一个叫 Store 的模块中,通过依赖注入到 Module 中,这样就没必要再通过 Delegate 从 View Controller 中拿数据,省去一些胶水代码。

5C2F2D49-2AFF-40D5-9B79-BACCAA995E91

Store 也使用 Protocol 定义,不同类型教室实现具体的 Store,当一个 Module 在多个教室复用时,虽然 Store 可能实现不一样,但 Store Service 的接口是一致的,使得 Module 在多教室复用变得容易。

直播命令调度拆分:Dispatcher

核心点:由业务模块主动注册其关心的直播命令,不再通过 View Controller 调度

直播命令的回调不再通过 View Controller 调用,直接发送到业务模块上,一方面能从 View Controller 删除很多胶水代码,另一方面能明确一个模块关心的直播命令。

DF78592C-7BCD-49AC-930A-206CF5A5E01A

实现一个直播命令注册与转发的模块:Dispatcher

  • 通过依赖注入到 Module 中,Module 使用 Dispatcher 显式声明自己需要的直播命令。
  • 参考 RxSwift 的思想,通过注册 Block 的方式实现通知,相比 Notification 通知,这样引擎消息参数处理起来更安全和方便。
  • Dispatcher 支持优先级,根据业务场景提供分发前、分发中、分发后三种队列。
  • 对于复合命令,由 Dispatcher 进行拆分后分发给业务 Module,业务 Module 不需要再关心复合命令细节,对其无感知。

教室容器化

在上面的设计中,Module、Layouter、Store 都是 Protocol,为什么要用 Protocol 呢?这种面向接口编程带来的灵活性是为了能够将教室 View Controller 变成一个容器,不再关心里面到底有哪些模块,如何排版布局等,所有类型教室共用该容器 View Controller:
6C13C0A4-E3B3-4371-8376-615882190670

至于该往教室容器中传入具体哪些 Module,使用哪种 Layouter 和 Store,这些策略交由 RoomFactory 生成。Factory 也是一个 Protocol,不同策略实现不同具体的 Factory,符合 OCP 原则。
E46A2299-F2AD-4081-8319-67488C20A080

基于容器化教室和策略工厂的设计,业务上能够根据配置决定加载哪些 Module,从而进行功能灰度和回退,或者针对一个 Service,有 A、B 两个实现 Module,根据配置进行加载,进行 A/B Test,极大的提升了灵活性。

推进过程

完成了重构的方案设计后,如何推进重构方案的落地是一件比方案设计更有挑战性的事情,需要脚踏实地的一点点啃掉:

  • 直播教室作为猿辅导的核心业务场景,一旦出问题直接影响用户核心体验,其稳定性要求高,如何保证重构方案能够比较平稳的落地?
  • 业务还在不断迭代,开发人力一直比较紧张,如何协调资源?重构任务应该如何安排,才能即不影响需求迭代速度,又能及时完成,不在同步业务最新改动时耗费大量精力?

做好重构规划

首先,直播教室既有老师端又有学生端,确定先重构老师端再重构学生端的方向:

  • 老师端是内部分发,用户也是内部老师,灰度范围、发 Fix 等更可控,风险要低一些。
  • 老师端是纯 Swift 实现,也不需要考虑回放场景和回放教室,重构方案更容易落地。

之后,就要规划出关键路径,寻找并行点,让能够并行的任务尽量并行:

  • 在完成重构方案设计后,开始实现基础定义,例如 Module、Layouter、Store 等定义,这些是关键路径,不完成的话会 Block 之后的工作。
  • 基于上一步的基础数据结构,对一个业务模块进行积木化改造,验证重构方案的可用性,并积累积木化改造的方案。
  • 完成一个模块之后,开始进人:Dispatcher 相对比较独立,可以交给一个同学负责;另外一个同学一起来对教室内相对基础和通用的模块进行积木化改造,为业务模块的改造提供前提。
  • 当 Dispatcher、Layouter 和基础模块完成改造后,就开始对一个教室进行重构,好处是:
    • 一个教室完成改造后就能自测和初步提测,验证积木化整体流程的稳定性,提前暴露底层实现的重大问题。
    • 教室间 70% - 80% 的业务模块是复用的,改造完一个教室,其他教室的工作量就小很多。
    • 改造完的教室可以做为模板,方便其他教室进行改造时进行参考。
  • 当完成了一个教室改造后,分工如下:
    • 一个同学负责自测后提测,并修复该教室一些严重问题。
    • 另一个负责实现模块的内存泄露检测工具,用于发现内存泄露问题。并编写积木化改造 101 文档,介绍对业务模块进行改造的方式和技巧。
  • 基于积木化改造 101 文档和已经改造完成的教室,进更多的人,每个人负责一个教室,这样能在短时间完成所有教室的改造。

把握住关键时间节点

重构什么时候开始搞,需要把握住关键时间节点才能降低成本,使得收益最大化。判断什么时候最合适,需要通过不断深入到业务中,分析规律,多和 PM、运营聊天,了解他们下一步的规划,在线教育的业务特点和上课时间比较有规律性,把握规律后,在很少会有新教室类型的时间段内,努力抓住时间节点推进重构的进行。

重复并不是所有情况下都是坏的

为了保证重构完成后线上的稳定性,需要先进行小规模灰度,监控被灰度用户的各项指标,在出问题时能够及时回退到重构之前的版本。如何保证回退没有问题?那就是旧教室旧逻辑完全保留,如果涉及到修改,就拷贝一份再修改,通过重复来确定重构前的环境没有变化,这样保证回退时能够回退到“和以前一模一样”,当积木化重构在线上平稳后,再将旧代码一起全部删除。

我们看一下积木化重构后的成果,看是否满足最开始定下的可量化目标:

  • 将多个 5000 行左右的 VC 合并为一个只有不到 300 行的容器 VC。
  • 消除一个业务功能在多个教室间的重复。
  • 新增教室复合 OCP 原则,不修改教室容器 VC,而是扩展 Factory。
  • 一个教室增删模块只需要改一行代码。

Beyond 技术

积木化整个重构过程,在技术之外还有很多感悟和收获,这里也想聊一聊:

关注人的因素

教室积木化是涉及到老师端、学生端核心业务场景的大重构,需要协调很多资源团队合作才能完成,那我们需要更关注人的因素,让参与进来的所有人都意识到重构不是炫技,不是开发瞎搞,而是件对大家都好的事情:

  • 对于开发同学,积木化重构解决的是大家的长期以来的痛点,“天下苦秦久矣”,用重构的设计方案和大家多描述重构之后的样子,大家就有动力参与进来。同时,重构过程中有很多活都是脏活累活,一个人做的话很容易疲劳和烦躁,一点经验是多几个人,大家分一分,一个人头上没几个,也能感觉到团队作战的优势。
  • 对于 PM 同学,在进行积木化重构方案设计时就不断同他们沟通,了解之后的长期迭代方向,并同步重构的作用是为了更好的支撑产品迭代,例如能够提供更灵活的配置与 A/B Test,例如能更快的增删模块,“给我一首歌的时间”就完成了。这样 PM 同学在需求排期上也愿意为重构协调时间。
  • 对于测试同学,虽然重构需要教室全功能回测,工作量较大,但是同样的,重构之后,由于少了很多重复代码,不容易遗漏,交付质量也会有所提升,增删模块的提测时间会更早,同时,在提测过程中,也及时同步了为什么先提测一个教室,再整体提测,测试同学也能更认可。
  • 对于上级 Leader,依次从能够更好支撑业务,提高代码质量,让开发同学写代码更开心等几个方面说明重构的意义,同时也提供了详细的设计文档和 Roadmap,于是 Leader 也认可这件事情,帮忙协调资源等。

总之,上面所有看起来像“影响力”的东西,都基于平时日积月累的“信任感”,做好每一个需求,认真对待交付质量和 Bug,多和 PM 沟通交流,与其他团队建立良好的关系,成为一个“靠谱”的工程师,这些东西终归在积木化重构上得到回报。

但行好事,莫问前程

直播教室由于业务方向上的不断快速迭代和探索,积累了大量技术债,导致无法通过简单重构解决,同时直播教室又是核心业务场景,对其进行大重构,风险不低,说心里话,是会害怕的,怕重构出故障,怕投入大量资源却没有完成。这时候需要的就是勇气,既然这件事经过判断是对的事情,能够为业务带来价值,同时也做好了设计和规划,就应该抛开其他想法,有勇气去把事情搞定。

有勇气开始后,设计出来重构方案时是激动的,但没有落地的方案都是“纸上谈兵”,而重构的落地过程是枯燥的,有很多脏活累活,有很多设计时没有想到的问题,没有捷径,只能“结硬寨,打呆仗”一点点解决,中间有想放弃的时刻,有很烦躁的时候,还是咬牙坚持了下来。

我是幸运的:iOS 团队的小伙伴们都很给力,大家一起努力把事情搞定。PM 和测试同学也非常支持,愿意协调排期。我的 Leader 全力支持,在业务压力较大的时候,协调了 Android 同学来写一些 iOS 需求,为积木化重构空出了 iOS 人力,也非常感谢 Android 同学的支援。

最终,有了勇气,有了坚持,再加上幸运,经过 2020Q4、2021Q1 两个 Q 的努力分别完成了老师端和学生端的教室积木化重构,所有的辛苦和投入在 2021Q2 得到了回报,这个 Q 上了 4 个新教室,证明之前判断的正确性,给自己带来极大的成就感和正反馈,难以想象如果没有经过积木化重构新增 4 个新教室的样子。

总之,但行好事,莫问前程。

背景

对于客户端的 Mock 工具,你可能第一时间想到是 Charles,为什么不用 Charles 进行 Mock 呢?因为对于直播场景的编程模式,与其他通过 HTTP 请求拉取数据并渲染的模式不太一样,有如下特点:

  • 服务器推送:在直播场景中,一般是由服务器将数据主动推送到客户端,而不是常见的由客户端发送 HTTP 请求拉取数据后渲染。推送数据的通道一般是基于 TCP 的长链接通道(也可能是 UDP),封装成 SDK 向使用方回调数据。
  • 二进制数据格式:数据格式上一般为了传输性能和效率,也不是人类可读的 JSON 格式,而是机器友好的 ProtocolBuffer 或者 FlatBuffer。
  • 数据获取方式并不唯一:有时为了性能,通过数据通道推送的只是一个 Trigger 命令,客户端在收到此命令后,再通过 HTTP 请求拉取数据。或者提交数据时使用的 HTTP 请求,后续的数据更新基于数据通道推送。
  • 活动中会存在多个角色的交互:例如,一个活动需要在一端操作开始,另一端才能参与,参与过程中还需要不断同步状态。
  • 有些活动是一次性的:一旦结束无法重新开始,需要重新配置直播间。

对于直播客户端的特点,在进行业务开发时会有以下痛点:

  1. 无论是服务器主动推送的方式、还是构造 ProtocolBuffer 这种格式的数据,都很难进行 Mock,有时还要配合着 HTTP 请求一起 Mock。
  2. 有时会依赖服务端或另外一个客户端开发完成,才能进行联调,当环境不稳定或者进度不同步时,会被 Block,联调出现问题时也不好排查。
  3. 直播对性能要求较高,压测需要服务端配合,灵活性和配置性不好保证。

因此,直播客户端需要一个 Mock 工具,能够对基于 TCP 长链接的 Protobuf 格式直播命令和基于 HTTP 请求的 JSON 格式 API 数据都进行 Mock,从而能够提高直播客户端同学的开发与联调效率。

方案

在设计 Mock 系统时,涉及的问题点有:

  • 如何推送 Mock 的直播命令?
  • 如何拦截 HTTP 请求?
  • 如何使 Mock 尽量不对业务代码造成干扰?
  • 触发 Mock 数据的交互方式是什么?
  • Mock 数据如何构造?有没有简便方式?

在思考这些问题点如何解决时,会发现将 Mock 逻辑放到客户端还是服务端,直接决定了问题解决方式的不同,因此有以下方案进行选择:

方案一:纯服务端逻辑

对于 TCP 和 HTTP 均实现一个中间层代理,代理中有一个白名单,对于匹配到白名单的项,返回 Mock 数据,对于不匹配的项,去源服务器拿数据。
优点是:对客户端透明,几乎不需要改动,需要一个开关配置是否走代理
缺点是:

  • TCP 代理比较麻烦,白名单也需要配置
  • 针对某个开发的独立配置不是很好搞,多个同学一起使用时可能会互相冲突
  • 需要服务器同学参与和维护,而 Mock 工具本身主要给客户端用,谁痛谁更有动力去解决

方案二:纯客户端逻辑

直播命令的 Mock 分发和 HTTP 拦截均在客户端本地做,交互也在客户端做,这样的好处是不依赖服务器端,简单一些,也相对可控。但是缺点主要是每个客户端需要实现一遍,而且对于移动端而言,界面较小,增加交互不方便,每次改数据可能都需要改代码,也比较麻烦。

方案三:客户端拦截 + 本地服务


针对方案二的问题,方案三进行优化,客户端只做分发 TCP 命令和 HTTP 拦截的功能,其他功能放到一个本地 Server 上:

  • 客户端实现 Hook 直播命令分发入口,允许 Mock 数据进行分发。
  • 客户端实现全局 HTTP 请求的拦截器,可以拦截特定 API 返回预置的 JSON 数据。
  • 客户端实现一个独立模块,用 WebSocket 连接本地 Server,本地 Server 负责下发 Mock 命令和 HTTP 拦截的数据。
  • 由本地 Server 实现交互界面,独立实现,可以在多端共用,同时由于是本地 Server,可以自己根据需求定制 Mock 策略,不会影响别的同学。

最终我们选用方案三作为最终方案,虽然每个客户端平台 iOS、Android、Electron 都要实现一遍 Client 端,但 Client 端的定位是尽量薄,只进行直播命令的分发和 HTTP 的拦截转发,将通用和复杂的逻辑与交互放到 Server 端,可以多端复用,不需要实现多遍。

架构

在确定了 C/S 架构方案后,基于 WebSocket 的通信通道,需要定义 Client 与 Server 之间的通信协议:协议类型使用 JSON,可读,方便扩展

1
2
3
4
{
“type”: “”
“payload”: {}
}

协议中,主要有两个字段:

  • type:用于描述通信消息类型,例如控制消息、直播命令、HTTP 拦截配置等。
  • payload:该类型消息的数据载体,不同类型的消息使用不同类型的结构,对于 Protobuf 这类二进制类型的数据,使用 Base64 进行编码,收到后再进行解码。

在消息通信中,除了 Client 与 Server 之间的控制消息外,主要是 Server 往 Client 推送的 Mock 数据,这些 Mock 数据有不同类型的直播命令,有 HTTP 拦截配置与数据,应该如何组织这些数据呢?为了更方便的管理与扩展 Mock 数据,引入几个基础的数据定义:

首先引入了 Action 的概念,在直播的 Mock 场景下,一般是由 Server 端推送数据给 Client 端,无论是 Mock 的直播命令,还是对 URL 的拦截设置与 Mock 数据,不关心数据到底是什么类型,均抽象为 Action,代表 Server 端告诉 Client 端要做的“动作”。

其次是 Scene,在 Action 的基础上,多个 Action 集合在一起成为 Scene(场景),因为在开发或自测过程中,有时经常需要多种数据集合,Scene 就是一种场景下所有 Mock Data 的配置,我们一般使用的就是 Scene。

Scene 我们使用 JSON 格式,因为 JSON 可读性强,使用方便,每个 Action 是一个 JSON 字典,而 Scene 就是一个包含多个 JSON 字典的 JSON 数组。

明确了数据协议,我们就分别看一下 Client 端和 Server 端的设计。

Client


对于 Client 端的设计,简单分为三层,如图所示:

  • 基础层:提供一些例如协议解析的基础能力
  • 功能层:每个模块负责一项独立功能
  • 业务层:通过调度功能层模块,描述业务逻辑

其中

  • Message Translator:负责解析 Client 端与 Server 端定义好的协议
  • Channel Connector:负责连接 Server 的 WebSocket 服务,解析协议,向上层提供回调。
  • Request Interceptor:负责接收配置,拦截 API 请求,将匹配的请求转给 Server 端处理。
    • 对于 iOS,使用 NSURLProtocol 进行全局的 API 拦截,对于正则匹配到的 URL,将其 Host 修改成 Mock Server 的地址。
    • 对于 Android,基于 OKHTTP Client 的 Interceptor,可以将匹配的 URL 进行转发。
  • Command Trigger:负责将直播命令分发出去。这里需要注意,作为一个 Mock 工具,我们应该尽量降低对业务代码的侵入性,因此
    • 对于 iOS,基于 Objective-C 的动态性特点,只需要拿到一个 id 类型的 target,使用 NSInvocation 就可以在没有 target 对应头文件的情况下,向该 target 发送消息。
    • 对于 Android,基于 Java 的反射机制,传入 Any 类型的一个 Object,在不显式依赖业务对象的情况下,通过反射对其进行调用。
  • Manager:负责 Client 端的上层策略,调度子模块。
  1. 调用 Channel Connector 连接 Server
  2. 将收到的 Mock Command 通过 Command Trigger 分发出去
  3. 将收到的 API Mock Config 通过 Request Interceptor 进行设置

Server

相对于 Client 端,Server 端的设计就更复杂一些,但核心依然是三层,多了一个 UI 显示层,用于负责用户交互。

对于基础层:

  • 和 Client 端一样,Server 端也有一个 Message Translator 来负责解析通信协议。
  • Logger:负责记录 Communicator 和 HTTP Server 运行过程中的日志。主要目的是有一个记录,可以查看 Mock 数据流的正确性。
  • Encoder:负责将 Action 中 JSON 格式的直播命令编码为 Protocol Buffer 格式。

而功能层:

  • Communicator :负责提供 WebSocket 服务,接收新连接,解析协议,将数据按照协议格式发送至各个连接端,或接受连接端的消息,解析后将消息交给上层消费。
  • HTTP Server:负责提供 HTTP Server 服务,接收 Mock 的 API 请求,返回特定的数据或者错误码。
  • Scene Loader:负责从本地加载 Scene 文件,以及将运行时创建 Scene 保存到本地。

再看下业务层:

  • Client Manager:负责管理 WebSocket 连接上的 Client,展示 Client 信息、断开连接、是否接受命令等。
  • Scene Manager:负责 Scene 的管理,包括创建新的 Scene、调用 Scene Loader 加载已有的 Scene,以及对 Scene 中的 Action 进行增加、删除、修改、移动位置等

最后,我们着重看一下 Mock 逻辑的“发动机” —— Player。再次回想下直播的特点,是由服务端主动推送数据和状态到客户端,在进行 Mock 时也遵循此特点,由 Action 描述要客户端做的事情,一个 Action 序列形成 Scene。那如何才能灵活高效的处理 Scene 呢?我们将处理 Scene 的过程抽象为“播放”:

  • Scene 是一个 Action 序列,每个 Action 都有 index,“播放”到该 Action 其实就是将该 Action 通过 Communicator 发送出去
  • 既然是播放,默认使用 next 播放下一个的策略,同时也支持随意更换当前播放 index 的能力

  • 通过增加一个 Timer,能够实现自动播放下一个的(即自动播放)的效果

  • 最后,当播放到 Action 序列末尾时,支持重置为 0,实现循环播放效果,配合上自动播放,可以实现自动重复循环播放的能力,而这项能力是客户端压力测试的关键。

下面我们看一个真实的案例,假如我们直播有一个投票功能,需要在一端触发,然后直播间所有其他用户均可以看到投票选项,并进行投票,投票后能看到不同选项的比例。

  • 开始投票和结束投票均通过直播命令 StartVote/EndVote 触发
  • 提交投票则是通过 HTTP POST 请求 room/{roomId}/vote 提交
  • 投票后的选项比例也是通过直播命令 VoteStatistic 来更新

在 Mock 出现前,直播客户端需要:

  1. 依赖服务器端、触发端都完成开发,达到联调状态才能开始联调
  2. 测试各种 Corner Case 状态比较困难,例如在 POST 请求过程中收到了 EndVote,例如 EndVote 之后再次 StartVote,收到了上一次的 VoteStatistic 等

有了 Mock 工具,只需要根据定义的协议,构造好 Scene 脚本,不依赖服务端和触发端即可进行联调和测试:

  1. 构造 Scene 脚本:
    • 首先建一个 URL 拦截配置,将匹配 room/{roomId}/vote 的 URL 转发到 Mock Server,并设置 delay 多久返回和返回码是多少
    • 构造 StartVote 命令,配置上 voteId 和选项个数,
    • 构造 VoteStatistic 命令,配置 voteId 和对应选项的数值
    • 构造 EndVote 命令,配置 voteId
  2. 完成 Scene 的构造后,通过 Player 加载该 Scene,然后执行 next 即可进行主流程的测试,同时,可以对 Scene 进行任意调整,用于测试上面说到的各种 Corner Case

压力测试

在 Mock 工具出现之前,当客户端一些场景(例如讨论区)需要压力测试时,一般都需要服务器端同学配合,由他们构造数据进行推送,内容、量级、时长都不是很好定制,也不能随时随地根据需求很快的构造出来压测环境。而现在基于 Mock 工具,我们只需要构造几条不同类型的消息,将播放策略调整为自动重复循环播放,设置好自动播放的 interval 间隔,就可以很方便的进行压力测试,且压力测试的参数可以自由定制。

在压力测试过程中,由于是重复播放,有些字段不适合使用 Scene 中固定的值,因此,在 Action 格式中,引入了“变量”的概念,用一个特殊的标识符+文本,标识出一个“变量”,在 Action “播放”时实时替换该“变量”值,例如:

  • #timestamp#:会替换为执行时的时间戳
  • #random#:会替换为一个随机值
  • #increase[\w]-[初始值]-[步长]#:自增器,会根据初始值 + 步长进行自增

以上就是直播教室 Mock 工具设计的全部内容了。

作为技术人员,一直很羡慕别人在基础设施领域做的一些很牛逼的工具和框架,虽然业务看起来就是在”搬砖“,但业务以及业务背后的服务才是一个公司的根本,这也是为什么有些公司技术并不牛逼,但发展却超出想象的原因。这并不代表对技术不重视,反而相反,将技术与业务结合起来,能够用合适的技术将业务支撑起来也是工程师的核心价值,毕竟工程师,就是“能将梦想照进现实的人”。

那作为一个业务团队,如何能够保持”技术前瞻性“,支撑业务的快速发展和迭代?所谓”前瞻性“,就是”晴天修屋顶“,听起来很好理解,但实际涉及到的问题有:

  • 如何判断什么时候是晴天,即什么时候需要修?
  • 应该修什么样的屋顶?
  • 用什么工具和办法修?

我就从下面几个方面,谈谈我的思考

真正深入业务,了解业务全貌,跟进业务走向

在业务开发团队,开发同学经常有的迷茫和吐槽是:

  • 感觉就是在搬砖,PM 给个需求就做,天天就在写需求,没意思,也没什么技术成长
  • 这个需求感觉好傻啊,为什么要这样搞?这个需求又大又急,为什么这么急?代码越搞越脏

当然,不排除有运营或者 PM 提一些“拍脑袋”的需求,但这是业务开发团队相对难以改变的,要么换家公司(我感觉这方面都差不多吧?),要么拍回去,剩下能做的,就是从开发团队本身看看能做什么?

在业务开发团队,我认为非常重要的一点就是”真正深入业务“,对于开发同学,可能容易只看到技术,忽略业务本身。但在业务开发团队,技术是支撑业务的,只有深入了解业务,才能在业务角度做出”前瞻性“。

了解公司业务全貌

《Netflix 文化手册》中的文化准则 2 是”要培养基层员工的高层视角“。我以前也觉得自己就是”搬砖”的,战略、业务啥的都是大佬考虑,自己做好活就行了,后来发现不对:

  • 公司需要的是聚焦,人多不一定力量大,人的力气往一处使才力量大,这也是 OKR 做聚焦的目的。当了解公司业务全貌后,能比较清楚知道自己做的工作是否和公司方向对齐,聚焦自己的工作。
  • 团队变多后团队之间的交互反而容易出问题,因为没有人能总览全貌,当流程较长且对流程不熟悉时,整个项目容易出问题。而熟悉业务全貌后,能够发现团队间交互问题,提前暴露风险。

做需求时多问问背景

我觉得很多时候开发人员需求做得恶心,并不是因为难或者有技术挑战,反而是因为觉得没有意义或者不知道有什么意义,那这个时候需求背景就显得很重要了。

  • 在看 PRD 时,我们经常忽略掉背景的 WHY,而只关注要做什么的 WHAT。当我们了解了公司业务,明白了需求背景,才能意识到这个需求有意义,做起来相对有动力一些,也更能从长期思维考虑,在实现时如何更全面。

跟进业务走向

有时候不明白为什么 PM 突然出了一个又急又大的活,这就需要我们多关注产品/UI OKR,多和 PM 聊聊天,提前探探他们之后想做哪方面的尝试,这样能够在技术上提前准备好。

  • 例如要更活泼的交互,那就多调研动画框架等
  • 例如要开更多的教室或课堂活动,那就做架构重构,提高配置灵活性等
  • 例如要提高运营或者生产效率,那就分析流程,将流程平台化等

关注人员/组织变化,提高对接效率

在业务团队,除了关注业务上的演进方向外,还有一方面特别容易被忽略,就是:人员(组织)变化。

随着业务需求,公司或者团队可能会快速扩充某个团队或者组建新的团队,当团队人数急剧增加或者需要和新团队频繁对接时(很多情况不是技术团队扩张,而是非技术团队扩张),原来一些手动操作的工作就成为的效率瓶颈,也会让业务团队的开发感到”烦躁“,觉得自己每天都在做一些琐碎工作;同时团队间如果依赖过重,导致互相影响,在联调和问题排查时也非常难受。

那业务团队可以做的“前瞻性”工作就是提高效率。具体措施是:

  • 将手动操作自动化:将之前手动跑的 SQL 或者操作脚本化,配置成 Job,自动定期跑 + 将结果推送给关心的人;将手动检查做成对非技术同学更友好的形式,搞成自动检查任务,每次自动检查。
  • 将配置/管理平台化:以前人少的时候还可以自己手动改改配置,手动发布之类,随着人数增加,需要实现一个配置/管理的平台,交给使用方根据自己需求进行配置和管理,业务团队的开发同学就可以从琐碎配置中解放出来,主力维护平台。
  • 隔离变化:当团队变多,需求的开发链条变长后,作为开发联调中的一环,需要隔离其他团队由于内部变化而导致的问题。

以上做法,除了提高效率外,还能应对团队规模变化,例如平台化了之后,由于对接的是平台,人数可以随意扩容,算法复杂度从 O(n) 降为 O(1)。

深入和扩充技术栈,真正用技术支撑业务

技术同学容易犯两种错误:

  • 新技术是”银弹“:拿着锤子看啥都是钉子,为了上新技术而上新技术,或者为了造轮子而造轮子,而不考虑业务的落地场景,是否真正解决痛点。
  • ”稳定压倒一切“:对新技术不敏感也没深入了解,要么对新技术嗤之以鼻,要么不知道有更合适的技术来解决业务问题,觉得保持现状挺好。

我个人觉得,第一个问题基础设施团队相对容易犯,第二个问题业务团队相对容易犯(只是个人看法,上面的问题我都犯过,现在也在不断自省,提醒自己不能迷恋”新技术“,也不能因为不了解某种技术就否定)。

因此对于业务团队,我认为能做的事情是:深入和扩充技术栈,真正用技术支撑业务。注意加粗的字,为什么要强调呢?

关于”深入和扩充“:在做业务开发时经常是够用就行,先尽快把业务实现,并不会深挖技术栈,也不想了解别的组是怎么实现的,但这样是不够的:

  • 深入技术栈:如果要做到极致性能,就是需要深挖所在的技术栈,不深入了解,遇到疑难问题找不到思路和问题分析,遇到业务难点不知道怎么实现和优化,导致”技术深度不够”,无论对个人成长(例如出去面试),还是公司内成长(成为技术专家,在团队内营造“信任感”)都是不利的。
  • 扩充技术栈:多扩充技术栈,一方面可以从别的技术栈学习好的思想和设计,看看能不能吸收自己所用的技术栈中;另一方面,多了解其他端的实现,并不是为了全栈一个人包圆了,而是在交流沟通上更加顺畅,也能站在更全面角度选择和评估技术方案。

关于”真正“:在深入和扩充技术栈后,开发同学手里有了“锤子”,特别想砸钉子,要忍住这种冲动。

  • 把自己想象成一个工匠,深入/扩充技术栈只是往自己的工具集中增加了一种工具,工匠最主要的工作是做工艺品,要根据具体情况选用合适的工具,眼睛盯着的是工艺品,而不是工具。
  • “真正”强调的是:在深入业务之后,针对业务中的痛点,看能否从新工具中找到一样合适的。

总之,通过深入和扩充技术栈,开发同学扩展了自己的工具集,可以针对业务中的痛点,用更合适的工具来解决问题,应对变化,达到“技术前瞻性”的目的。

解决问题时多思考长期改进

在遇到一些疑难问题时,多写总结,不能解决了就过去了,从长期角度可以看看有什么改进,这样也能保证“技术前瞻性”:

  • 例如出现了内存泄露,把泄露处的代码改了是解决了问题,但从长期改进看,能不能搞一个内存泄露检测工具?
  • 例如出现了性能卡顿,优化卡顿出代码也解决了问题,但从长期改进看,需要 APM 监控和自动化压力测试等

最后,我们再尝试回答最一开始的问题:

  • 什么时候需要修?跟进业务走向 + 关注人员/组织变化 + 遇到问题时关注长期角度
  • 应该修什么样的屋顶?真正深入业务,了解业务全貌 + 思考长期改进
  • 用什么工具和办法修?深入和扩充技术栈,手动操作自动化,配置平台化,隔离变化

Algorithm

Validate Binary Search Tree - LeetCode

比较简单,二叉搜索树的特性是节点的左子树小于节点的值,右子树大于节点的值。本质就是二叉树中序遍历的应用,中序遍历二叉搜索树得到的数组一定是有序的。

Review

Understanding Swift Performance - WWDC 2016 - Videos - Apple Developer
没看完,主要看了 Allocation 和 Dispatch 部分,准备看完后写一个 Swift 性能相关的文章,嗯,下周的 Share 有了。。。

Tips

  1. 如何从远端 Git 仓库中拉取某个制定文件:git - Retrieve a single file from a repository - Stack Overflow
  2. 使用 Fastlane 快速更新 Push 证书:fastlane pem -a bundle_id -u username -p "password" --force --development

Share

重构技巧:Parser 与多态

对于 Parser,一般我们能想到的是同一个数据流,根据协议或者格式的要求进行区分,解析成不同含义的元素。这个解析过程一般存在着复杂的条件逻辑,用于匹配协议或者格式的要求。

抽象一下,可以将 Parser 看成有复杂条件逻辑处理同一数据流的场景。而复杂的条件逻辑是编程中最难理解的东西之一,复杂的 if/else 或者 switch/case 中包含了许多细节,容易引入 Bug ,也使得修改变得麻烦。

多态

在《重构 Refactoring》这本书中,针对上面的问题,有个技巧被称为“以多态取代条件表达式”(Replace Conditional with Polymorphism)

这个技巧的核心在于,将每一条分支逻辑隔离到一个类中,用多态来承载各个类型特有的行为,“上层”或者“业务层”不再关心每一条分支的具体细节,不再事事躬亲,只作分发(Dispatch)的工作。

案例

JS 回调重构

最早的 WebViewController 在处理 JS 回调的方法是用一堆 if/else 语句:

1
2
3
4
5
6
7
8
9
10
11
- (void)jsCallback:(NSString *)name arguments:(NSDictionary *)arguments {
if ([name isEqualToString:@“command1”]) {
[self handleCommand1:name arguments:arguments];
} else if ([name isEqualToString:@“command2”]) {
[self handleCommand2:name arguments:arguments];
} else if ([name isEqualToString:@“command3”]) {
[self handleCommand3:name arguments:arguments];
} else if ([name isEqualToString:@“command4”]) {
[self handleCommand4:name arguments:arguments];
}
}

这样写的问题是导致 WebViewController 越来越庞大,一堆业务逻辑耦合到 WebViewController 中(例如登录通知,语音跟读的回调等),维护性变差。另外,如果想配置 WebViewController 只支持某些或者不支持某些 JS 特定的回调的话,甚至根据页面 URL 进行动态调整,也不是很干净。于是趁着 UIWebView 升级 WKWebView,做了一次重构:基于命令模式,将 JS 回调的处理抽离到一个个 Handler 中,JS 回调的名称和参数也在 Handler 中维护,WebViewController 中不再含有任何与 WebView 无关的业务逻辑,当 WebView 触发了 JS 回调后,调用 Command Manager 这个 Invoker 去调用 Command。

1
2
3
4
5
6
7
8
9
10
11
- (void)registerCommands {
[self.commandManager registerCommand:[Command1Handler new]];
[self.commandManager registerCommand:[Command2Handler new]];
[self.commandManager registerCommand:[Command3Handler new]];
[self.commandManager registerCommand:[Command4Handler new]];
}

- (void)jsCallback:(NSString *)name arguments:(NSDictionary *)arguments {
JSCommand *command = [JSCommand commandWithName:name arguments:arguments];
[self.commandManager handleCommand:command];
}

图片标注操作栈

对于图片标注功能,支持笔迹、图片、文本、橡皮擦、套索等,同时有 Undo、Redo、ClearAll 等操作。

由于涉及到 Undo、Redo 操作,因此需要维护一个操作栈。基于此,需要将每种操作抽象成 Action,Action 中有 type 属性,用于描述 Action 的具体类型。同时定义 ActionManager 的类,负责维护操作栈,并基于操作栈实现 Undo、Redo 操作。

一开始的代码可能是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
- (void)undo {
if (self.currentIndex <= 0) {
return;
}
self.currentIndex--;
Action *action = self.actions[self.currentIndex];
if (action.type == ActionTypeStroke) {
// handle stroke
} else if (action.type == ActionTypeLasso) {
// handle lasso
} ...
}

在 undo/redo 方法中,除了处理操作栈外,需要根据 Action 的不同,处理该类型 Action 在 undo 时应该做的事情。但回过头来看看 ActionManager 的职责,其没有必要了解 Action 的具体细节,因此,Action 应作为基类或者接口,定义 do/undo 两个方法,各个子类 Action 实现 do/undo 方法,分别在 ActionManager 在 redo/undo 中调用。这样修改之后,ActionManager 的逻辑变得清晰:

1
2
3
4
5
6
7
8
- (void)undo {
if (self.currentIndex <= 0) {
return;
}
self.currentIndex--;
Action *action = self.actions[self.currentIndex];
[action undo];
}

SVG 解析库

最近看了下 Skia 中 SVG Parser 的源码,虽然 Parser 中 switch 语句依然存在,但是 switch 中只是针对不同的标签(Path、Line、Rect、Circle 等)生成不同的 Element,至于如何解析 Element,由各个 Element 子类实现 Element 基类中定义的 translate 方法,负责解析出各自类型 Element 中的属性。

Algorithm

Maximum Width of Binary Tree - LeetCode

还是二叉树相关的题目,不管是否简单与否,按照模块进行训练比较成体系一些。(其实是周末带娃太累,刷不了复杂的题。。。)
计算二叉树的最大宽度这道题本身比较简单,主要有一个思维转换,所谓二叉树的宽度,就是每一层的节点个数,看到层,就转换为二叉树的层序遍历,使用队列,计算每一层节点个数,最后算出最大值即为二叉树的宽度

Review

iOS Memory Deep Dive - WWDC 2018 - Videos - Apple Developer

WWDC 2020 要来了,看了下 2018 年关于 iOS 内存的一个 Session:

内存占用

Pages Memory 和 Page Fault 没什么好说的,OS 基础知识。
iOS 上内存可以分成三类:

  1. Clean Memory:可以 Page Out 的内存,例如代码段
  2. Dirty Memory:被 App 写入过数据的内存,例如堆、图片解码区
  3. Compressed Memory:iOS 设备由于存储硬件的特性,并不会像桌面端一样进行 Swap,而是直接 Page Out。但从 iOS 7 开始,统开始采用压缩内存的办法来释放内存空间,被压缩的内存称为 Compressed Memory,再次访问时会先解压。因此,如果在收到 Memory Warning 时去释放被压缩内存,由于被解压,导致内存用的更多。。。

在一些缓存数据场景,建议用 NSCache 替换 NSDictionary,因为 NSCache 会根据系统情况自动清理内存。

内存占用分析工具

  • malloc_history:查看内存分配历史
  • leaks:查看泄漏内存
  • vmmap:查看虚拟捏成
  • heap:查看堆内存

一些调试技巧:

  • Xcode Memory Debugger 可以看内存中所有对象的内存使用情况和依赖关系
  • 在 Product -> Scheme -> Edit Scheme -> Diagnostics 打开 Malloc Stack(Live Allocations Only),可以定位占用过大内存

图片

图片在使用时,会将 jpg/png/webp 解码成 Bitmap,对于 RGBA,一个像素就是 4 字节,使用建议:

  • 使用 UIGraphicsImageRenderer 替代 UIGraphicsBeginImageContextWithOptions,iOS 12 上会自动选择格式,例如黑白图或单色,会讲 RGBA 降为 1 字节。
  • 修改颜色,建议用 tintColor,不会有额外的内存开销。
  • Downsampling 图片时,一般会先解码,然后搞一个小的画布进行渲染,解码还是造成内存峰尖。因此建议使用 ImageIO 框架,CGImageSourceCreateThumbnailAtIndex 不会造成图片解码。

Tips

最近看同事的分析,发现还会有两点导致系统杀 App:

一些第三方 SDK 例如环信会导致这个问题: http://www.easemob.com/question/13822 ,至于环信的原因,有大佬用 Hopper 反解了 EMClient 的 -applicationDidEnterBackground: 方法,如下图。可以看到 isLoggedIn 方法,与登陆了之后才会被 kill 现象完全吻合。

至于被 Kill 的原因,是因为其调用了 beginBackgroundTaskWithExpirationHandler,而此方法要求在 expiration 到期前调用 endBackgroundTask,需要成对调用,否则系统会杀掉 App,具体见:
https://developer.apple.com/documentation/uikit/uiapplication/1623031-beginbackgroundtaskwithexpiratio

高级,是时候学一波逆向和 Hopper 了

Share

重构技巧:数据选择器与中间层

Any problem in computer science can be solved by anther layer of indirection

在计算机领域有句名言:“计算机科学领域的任何问题都可以通过一个中间层来解决”,能找到很多例子:

  • 虚拟内存: 为了更好的隔离和管理内存,在程序和物理内存之间增加虚拟的内存控件作为中间层。
  • 操作系统:为了防止应用程序直接(随意)访问硬件,也为了降低使用硬件的复杂度,操作系统和驱动程序来作为中间层。
  • JVM:Java 通过构造一个 JVM 虚拟机,隔离了不同平台的底层实现,使得 Java 的字节码可以多个平台上不加修改地运行。
  • 其他还有很多,例如 TCP/IP、汇编等。

总之,中间层的核心思想,是通过层与层之间的接口,隔离两个层各自的细节和变化。这种间接性 Indirection 的思想除了在架构设计上得到应用,在一些需求变化导致的重构场景也比较适合,这类场景我称为“多路开关”,或者也可以叫“数据选择器”,具体请看下面。

数据选择器

在电子技术(特别是数字电路)中,数据选择器(Data Selector),或称多路复用器(multiplexer,简称:MUX),是一种可以从多个模拟或数字输入信号中选择一个信号进行输出的器件。

在软件开发中,多个输入对应一个输出的场景也比较常见:列表页原来只使用一种类型的数据,在 TableView 的 DataSource 中都是直接使用对应的 Model,随着需求变化,多了一种类型数据,而 Cell 样式是相同的。

此时应该如何修改代码来比较稳的应对这样的变化?直接改吗?那原来使用数据的地方会多一堆 if/else 条件语句,难以维护不易读,如果再增加一条数据源,数据使用的地方还需要再次改动。。。

这类场景,和数字电路中的数据选择器类似,多路数据输入一路数据输出,由选择器负责切换数据输入。参考相同思路,也构造“数据选择器”:

  1. 抽取中间层,构造“选择器”,重构原有代码接入选择器。抽象层可以用数据抽象,也可以用一个函数封装获取数据的方法,并将“选择器”相关的代码集中到一起,方便维护和处理。
  2. 测试重构后的代码。由于第一步是通过抽取中间层构造“选择器”,数据的消费方不再是直接访问原来的数据,而是通过“选择器”获取数据,因此需要进行测试,保证没有重构出问题,那下一步接入新数据出现问题,就是“选择器”在选择时有问题。
  3. 接入新的数据源。基于前面的重构,这一步的接入变得简单,专注于在“选择器”代码中根据业务需求选择走哪条数据源,数据消费部分的代码和逻辑完全不需要修改,同时选择器的选择逻辑也可以抽出来进行单测。

快速投票模块

直播教室内的快速投票原来是基于 Ballot 命令进行显示消失的,后来服务器换成 WidgetState 的方案,相当于一条新的数据源,并且服务器期望根据 Config 配置到底使用哪条数据源。

重构就是分三步走:

  1. 将原来直接使用 Ballot 的地方抽到函数中,并将 Ballot 和 WidgetState 中共同使用的数据抽出来,使用无依赖的基础数据类型描述(例如 bool 或字典等),原来直接用 Ballot 的消费方,现在使用这些基础数据类型。
  2. 测试第一步的重构。
  3. 接入 WidgetState 的数据,在第一步的函数中,根据 Config 决定是从 Ballot 转还是从 WidgetState 转。

笔迹库重构

以前笔迹库只渲染笔迹,所有的操作栈(Undo、Redo、Lasso、ClearScreen)等都和笔迹 View 绑定较死,现在由于要接入图形,是一个新的数据源。

重构依然是分三步走:

  1. 将操作栈从笔迹 View 中抽出,由一个专门的 Manager 来负责管理,并生成每一步的渲染数据,笔迹 View 就负责渲染笔迹相关的数据。
  2. 测试第一步的重构。
  3. 在 Manager 中接入图形数据,并实现一个图形 View 负责渲染图形相关的数据。

同时,为了保证上线的稳定性,需要有开关回退,那新旧两个 View 都在,根据开关进行区分,又是一个“数据选择器”。于是抽象一个 Protocol 做为中间层,外部使用是 UIView,由这个中间 View 根据开关切换。

最后来回顾一下所谓的“选择器“,这有什么新的东西吗?仔细看下“选择器”代码,其实就是由于多了一路数据而导致的变化,抽取中间层相当于将变化隔离开,嗯,最后还是“隔离变化”,万变不离其宗。

Algorithm

Invert Binary Tree - LeetCode

决定从递归思想 + 树开始练习,周末家里有突发情况,所以选了一道简单+“有名”的翻转二叉树。树的结构由于自带子节点,所以很适合递归思想,对于这道题,翻转二叉树就是递归交换子树,递归起来可以有两种思路:

  1. 每次交换的是左右子树,至于左右子树的结果,调用 invertTree 获取

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Solution {
    public:
    TreeNode* invertTree(TreeNode* root) {
    if (root == nullptr) {
    return nullptr;
    }
    TreeNode *tmp = invertTree(root->left);
    root->left = invertTree(root->right);
    root->right = tmp;
    return root;
    }
    };
  2. 每次交换左右子节点,然后再递归调用左右子节点

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Solution {
    public:
    void invertTree(TreeNode* root) {
    if (root == nullptr) {
    return nullptr;
    }
    TreeNode *tmp = root->left;
    root->left = root->right;
    root->right = tmp;
    invertTree(root->left);
    invertTree(root->right);
    }
    };

Review

Why iOS Developers Feel Stuck In Their Careers & What To Do — Essential Developer

对于目前阶段的我而言,一直处于焦虑之中,一方面随着工作年限变多,无论是自己还是“业界”,对于自己的要求变得更高,另一方面,iOS 或者客户端相对服务器端而言,离业务有点远,核心竞争力并不突出。看到这篇文章,没想到”浓眉大眼“ Work Life Balance 的国外 iOS 同行也会 Feel Stuck。。。

文章的核心观点和我的看法是:

  • 不要过于急功近利,设置不切实际的目标。学习的过程是曲折向上的,需要花时间持续投入,不要期望有立竿见影的效果,先坚持一段时间再说(例如 ARTS 活动)。
  • Feeling Stuck 的原因有时候和工作环境有关,有些事可以缓解,例如同优秀的人合作(remarkable people),例如团队中有 mentor 可以指导如何高效的写和维护高质量的代码等等。关于环境这块我是这样想的,虽然环境会影响人,但个人是可以潜移默化影响环境的,要做“催化剂”
  • 将工作中遇到的挑战与技术成长结合起来。工作不仅仅是编程或者技术本身,即使是一个简单的需求,那如何在 Commit 拆分上更清楚,如果是重复劳动,能否有一些自动化的工具来提高效率?在遇到例如网络超时问题时,能否更深入的排查,使用 Charles + WireShark 工具,重新翻看 TCP/IP 等书籍查漏补缺?另外,软技能一样重要,沟通能力、领导力等,也可以提高。

Tips

  1. 当 UILabel 的 adjustsFontSizeToFitWidth 为 YES 时,UILabel 会根据内容多少来调整字体大小,但是此时 baseline 不变,会导致文字在 Y 轴不居中。解决办法是:将 baselineAdjustment 设置为 UIBaselineAdjustmentAlignCenters。
  2. UIScrollView 的 directionalLockEnabled 能够锁死每次滑动只影响一个方向,但是当滑动是对角线的情况,就失效了,需要在 beganDragging 时记录初始 offset,并将 direction 初始化为 .none,在 DidScroll 时判断 direction 类型,并根据 vertical 或 horizontal 来设置 contentOffset,最后在 DidEndDecelerating 和 DidEndDragging(willDecelerate 为 false)时重置为 .none
  3. Swift 会对其符号进行修饰(Name Mangling),具体原理见:mikeash.com: Friday Q&A 2014-08-15: Swift Name Mangling。在 Bugly 上,如果崩溃在 Swift 方法中,被修饰过的命名就很难读了。Xcode 提供了对 Swift 符号进行 demangling 的工具,在命令行输入 xcrun swift-demangle之后,将对应的 Swift 符号拷入,点回车,就能看到解析后的结果了

Share

CNAME 有什么用?

之前一直好奇,公司用的 CDN 是公司域名,是如何转到阿里云或者腾讯云的?后来翻看了 DNS 的一些知识,发现和 CNAME 有关:

CNAME 是 Canonical Name 的缩写,也成为别名指向。

DNS 中 CNAME 记录和 A 记录的区别在于,A 记录是把一个域名解析到一个 IP 地址(Address,这也是 A 记录名字的原因),CNAME 记录是把一个域名解析到另一个域名,相当于加了一个中间层。

通过 dig 能看到 DNS 域名解析的过程,具体见:https://www.ruanyifeng.com/blog/2016/06/dns.html

CNAME 有什么用?有句话是”在软件开发中,没有什么是加一个中间层搞不定的,如果不行,就再加一层“,哈哈,开个玩笑。CNAME 加中间层的好处是:

  • 多个域名都指向同一个别名,当 IP 变化时,只需要更新该别名的 IP 地址(A 记录),其他域名不需要改变
  • 有的域名不属于自己,例如 CDN 服务,服务商提供的就是一个 CNAME,将自己的 CDN 域名绑定到 CNAME 上,CDN 服务提供商就可以根据地区、负载均衡、错误转移等情况,动态改别名的 A 记录,不影响自己 CDN 到 CNAME 的映射。