一 通过 Universal Links 和 App Links 优化唤端启动体验
App 都会存在拉新和导流的诉求,如何提高这些场景下的用户体验呢?这里会用到唤端技术。包含选择什么样的换端协议,我们先看看唤端路径,如下:
唤端的协议分为自定义协议和平台标准协议,自定义协议在 iOS 端会有系统提示弹框,在 Android 端 chrome 25 后自定义协议失效,需用 Intent 协议包装才能打开 App。如果希望提高体验最好使用平台标准协议。平台标准协议在 iOS 平台叫 Universal Links,在 iOS 9 开始引入的,所以 iOS 9 及以上系统都支持,如果用户安装了要跳的 App 就会直接跳到 App,不会有系统弹框提示。相对应的 Android 平台标准协议叫 App Links,Android 6 以上都支持。
这里需要注意的是 iOS 的 Universal Links 不支持自动唤端,也就是页面加载后自动执行唤端是不行的,需要用户主动点击进行唤端。对于自定义协议和平台标准协议在有些 App 里是遇到屏蔽或者那些 App 自定义弹窗提示,这就只能通过沟通加白来解决了。
另外对于启动时展示 H5 启动页,或唤端跳转特定功能页,可以将拦截判断置前,判断出启动去往功能页,优先加载功能页的任务,主图相关任务项延后再加载,以提升启动到特定页面的速度。
二 H5启动页
现在 App 启动会在有活动时先弹出活动运营 H5 页面提高活动曝光率。但如果 H5 加载慢势必非常影响启动的体验。
iOS 的话可以使用 ODR(On-Demand Resources) 在安装后先下载下来,点击启动前实际上就可以直接加载本地的了。ODR 安装后立刻下载的模式,下载资源会被清除,所以需要将下载内容移动到自定义的地方,同时还需要做自己兜底的下载来保证在 On-Demand Resources 下载失败时,还能够再从自己兜底服务器上拉下资源。
On-Demand Resources 还能够放很多资源,甚至包括脚本代码的预加载,可以减少包体积。由于使用的是苹果服务器,还能够减少 CDN 产生的峰值成本。
如果不使用 On-Demand Resources 也可以对 WKWebView 进行预加载,虽然安装后第一次还是需要从服务器上加载一次,不过后面就可以从本地快速读取了。
iOS 有三套方案,一套是通过 WKBrowsingContextController 注册 scheme,使用 URLProtocol 进行网络拦截。第二套是基于 WKURLSchemeHandler 自定义 scheme 拦截请求。第三套是在本地搭建 local server,拦截网络请求重定向到本地 server。第三套搭建本地 server 成本高,启动 server 比较耗时。第二套 WKURLSchemeHandler 使用自定义 scheme,对于 H5 适配成本很高,而且需要 iOS 11 以上系统支持。
第一套方案是使用了 WKBrowsingContextController 的 registerSchemeForCustomProtocol: 这个方法,这个方法的参数设置为 http 或 https 然后执行,后面这类 scheme 就能够被 NSURLProtocol 处理了,具体实现可以在这里[1]看到。
Android 通过系统提供的资源拦截Api即可实现加载拦截,拦截后根据请求的url识别资源类型,命中后设置对应的mimeType、encoding、fileStream即可。
三 下载速度
App 安装前的下载速度也直接影响到了用户从选择你的 App 到使用的体验,如果下载大小过大,用户没有耐心等待,可能就放弃了你的 App,4G5G 环境下超 200MB 会弹窗提示是否继续下载,严重影响转化率。
因此还对下载大小做了优化,将 __TEXT 字段迁移到自定义段,使得 iPhone X 以前机器的下载大小减少了50M,几乎少了1/3的大小,这招之所以对 iPhone X 以前机器管用的原因是因为先前机器是按照先加密再压缩,压缩率低,而之后机器改变了策略因此下载大小就会大幅减少。Michael Eisel 这篇博客《One Quick Way to Drastically Reduce your iOS App’s Download Size》[2] 提出了这套方案,此方案已经线上验证,你可以立刻应用到自己应用中,提高老机器下载速度。
Michael Eisel 还用 Swift 包装了 simdjson[3] 写了个库 ZippyJSONDecoder[4] 比系统自带 JSONDecoder 快三倍。人类对速度的追求是没有止境的,最近 YY 大神 ibireme 也在写 JSON 库 YYJSON[5] 速度比 simdjson 还快。Michael 还写个了提速构建的自制链接器 zld[6],项目说明还描述了如何开发定制自己的链接器。还有主线程阻塞(ANR)检测的 swift 类 ANRChecker[7],还有通过 hook 方式记录系统错误日志的例子[8]展示如何通过截获自动布局错误,函数是 UIViewAlertForUnsatisfiableConstraints ,malloc 问题替换函数为 malloc_error_break 即可。Michael 的这些性能问题处理手段非常实用,真是个宝藏男孩。
通过每月新增激活量、浏览到新增激活转换率、下载到激活转换率、转换率受体积因素影响占比、每个用户获取成本,使用公式计算能够得到每月成本收益,把你们公司对应具体参数数值套到公式中,算出来后你会发现如果降低了50多MB,每月就会有非常大的收益。
对于 Android 来说,很多功能是可以放在云端按需下载使用,后面的方向是重云轻端,云端一体,打通云端链路。
下载和安装完成后,就要分析 App 开始启动时如何做优化了,我接下来跟你说说 Android 启动 so 库加载如何做监控和优化。
四 Android so 库加载优化
1 编译阶段 - 静态分析优化
依托自动化构建平台,通过构建配置实现对源码模块的灵活配置,进行定制化编译。
-ffunction-sections -fdata-sections // 实现按需加载
-fvisibility=hidden -fvisibility-inlines-hidden // 实现符号隐藏
这样可以避免无用模块的引入,效果如下图:
2 运行阶段 - hook分析优化
Android Linker 调用流程如下:
注意,find_library 加载成功后返回 soinfo 对象指针,然后调用其 call_constructors 来调用 so 的 init_array。call_constructors 调用 call_array,其内部循环调用 call_funtion 来访问 init_array 数组的调用。
高德 Android 小伙伴们基于 frida-gum[9] 的 hook 引擎开发了线下性能监控工具,可以 hook c++ 库,支持 macos、android、ios,针对 so 的全局构造时间和链接时间进行 hook,对关键 so 加载的关键节点耗时进行分析。dlopen 相关 hook 监控点如下:
static target_func_t android_funcs_22[] = {
{"__dl_dlopen", 0, (void *)my_dlopen},
{"__dl_ZL12find_libraryPKciPK12android_dlextinfo", 0, (void *)my_find_library},
{"__dl_ZN6soinfo16CallConstructorsEv", 0, (void *)my_soinfo_CallConstructors},
{"__dl_ZN6soinfo9CallArrayEPKcPPFvvEjb", 0, (void *)my_soinfo_CallArray}
};
static target_func_t android_funcs_28[] = {
{"__dl_Z9do_dlopenPKciPK17android_dlextinfoPKv", 0, (void *)my_do_dlopen_28},
{"__dl_Z14find_librariesP19android_namespace_tP6soinfoPKPKcjPS2_PNSt3__16vectorIS2_NS8_9a"},
{"__dl_ZN6soinfo17call_constructorsEv", 0, (void *)my_soinfo_CallConstructors},
{"__dl_ZL10call_arrayIPFviPPcS1_EEvPKcPT_jbS5_", 0, (void *)my_call_array_28<constructor_func>},
{"__dl_ZN6soinfo10link_imageERK10LinkListIS_19SoinfoListAllocatorES4_PK17android_dlextin"},
{"__dl_g_argc", 0, 0},
{"__dl_g_argv", 0, 0},
{"__dl_g_envp", 0, 0}
};
Android 版本不同对应 hook 方法有所不同,要注意当 so 有其他外部链接依赖时,针对 dlopen 的监控数据,不只包括自身部分,也包括依赖的 so 部分。在这种情况下,so 加载顺序也会产生很大的影响。
JNI_OnLoad 的 hook 监控代码如下:
#ifdef ABTOR_ANDROID
jint my_JNI_ONLoad(JavaVM* vm, void* reserved) {
asl::HookEngine::HoolContext *ctx = asl::HookEngine::getHookContext();
uint64_t start = PerfUtils::getTickTime();
jint res = asl::CastFuncPtr(my_JNI_OnLoad, ctx->org_func)(vm, reserved);
int duration = (int)(PerfUtils::getTickTime() - start);
LibLoaderMonitorImpl *monitor = (LibLoaderMonitorImpl*)LibLoaderMonitor::getInstance();
monitor->addOnloadInfo(ctx->user_data, duration);
return res;
}
#endif