Skip to content

Hybrid 架构演进

顺着工作经验,来讲一下实际工作中hybrid方案的演进,其核心目标就一个,实现页面秒开的效果,以此来获得好的用户体验。

1.0 优化资源加载

最开始我们网页是直出的,除了设置了简易的缓存头,其它的全部考浏览器自己的缓存策略。 所以整个页面的加载时间就如下图 用户看到所消耗时间 = webview初始化 + 资源请求 + 代码解析 + 浏览器renderalt text

所以我们在浏览器中即使打开 webview 快的情况下也得 1500ms左右,所以我们公司这一阶段主要针对以下两段做了动作

  • 资源解析
  • 资源加载

资源解析

发起一个请求的链路是 DNS - > TCP - > TLS

  • DNS 是域名解析服务器,负责解析域名背后的 IP 地址
  • TCP 是用来和服务器建立连接,这一块就是老生常谈的 三次握手了 RTT = 三次握手
  • TLS 是用来建立加密传输数据,HTTPS握手

DNS 域名解析时间据统计第一次一般是20 - 80 ms,第二次由于 DNS 缓存机制,约等于 0ms.已知乎为例,第一次 DNS 查询时间为14.5ms,第二次就是 2ms了。 (补充一个点:DNS 缓存的失效时间,不同浏览器不同过期时间不同, TTL 大多数都在 1 -5 分钟 )

alt text

alt text

所以优化方向就出来了,尽可能的 DNS 预解析和域名提前建立链接。

  • 提前解析 api 域名
  • 静态资源的 CDN 域名
  • 跳下一页面

一个完整的域名建立连接大概花时(180~300ms) = DNS(50ms) + TCP(80ms) + TLS(50ms)

优化点一: HTML本身页面,使用 prefetch/preconnect,遇到这种标签就提前预解析域名,节省掉 DNS 解析的时间。

html
<!-- html解析之后触发, DNS 解析 -->
<link rel="dns-prefetch" href="//www.zhihu.com" /> // 预解析知乎域名

<!-- html解析之后触发,DNS + TCP + TLS, 更强 -->
<link rel="preconnect" href="//www.baidu.com" /> // 预解析百度域名

但预解析有一定的弊端,就是无法预解析 入口文档的 DNS ,比如我 app 内 入口文档是 https://www.baidu.com, 那么这块的DNS 时间是无法解析的。

我们可以从浏览器的网络面板上看到加载过程

alt text

优化点二: 让 APP 利用 OkHttp 创建一个连接池,因为能完整支持http1.1与HTTP2的功能,也就支持连接复用。

text
1. 只放关键的域名,域名收敛
2. 维护连接池,动态维护池子预解析域名
3. 使用 head 请求,请求方式最轻量,完成整个HTTP 整个链路,DNS + TCP + TLS

资源加载

优化点三: H5加载层面优化

既然我们的目的是为了让 MCP 时间更快的到来,所以我们做了以下优化点

  • 资源加载顺序,先加载 CSS、 后加载 JS, js文件使用 defer 属性
html
<!-- 资源的下载几乎是同时进行的
- css 解析完成后,cssom 构建完成后才会加载 js
- js 的加载(现在浏览器在后台并行下载)和执行解析会阻塞 html parser,所以script 标签会有 defer
- 资源的下载不会影响 html文件的下载
- html的解析流程是: css->cssom  cssom & dom树 -->
render tree -->
<head>
  <link href="A.css" rel="stylesheet" />
  <!-- 下载+构建 CSSOM -->
  <link href="B.css" rel="stylesheet" />
  <script src="a.js" defer></script>
  <script src="b.js" defer></script>
  <script src="c.js" defer></script>
  <script src="d.js" defer></script>
</head>

alt text

  • 异步组件/资源的加载,脚手架split
js
// 异步加载资源
import("./component.js").then((module) => {});
  • 首屏使用 SSR
js
// ~/nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 1. 首页:SSR 模式
    // 服务端渲染,确保首屏加载速度和 SEO
    "/": { ssr: true },

    // 2. 其他全平台通配符页面:SPA 模式
    // 例如 '/about', '/products/1' 等页面都将作为纯客户端渲染
    "/**": { ssr: false },
  },
});
  • 使用http2协议&静态资源 CDN 加速

alt text

  • 高效资源替代,判定是否支持 webp
js
  /**
   * 客户端是否支持webp
   */
  get canUseWebp(): boolean {
    return (!this.isIOS && !this.isAndroid) || (this.isIOS && check.compareVersion(this.iosVersion, '14.0.0') >= 0) || (this.isAndroid && check.compareVersion(this.androidVersion, '4.2.0') >= 0)
  }

// 通过阿里云转换成 webp 格式
function convertMore(url: string) {
  const convertWebp: boolean = env.canUseWebp
  if (convertWebp) {
    if (url.indexOf(WEBP_SUFFIX) < 0) {
      url += '/format,webp'
    }
  } else {
    if (url.indexOf(WEBP_SUFFIX) > 0) {
      url += '/format,jpg'
    } else if (url.indexOf(PNG_SUFFIX) > 0) {
      url += '/format,jpg'
    }
  }
  if (url.indexOf('jpg') > 0 && !convertWebp) {
    url += '/interlace,1'
  }
  url = url.replace('http://', 'https://')
  return url
}
  • 同步渲染采用chunk编码(这个没用过) transfer-encoding chunked
js
app.get('/page', async (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' });
  // 立即输出首屏骨架
  res.write(`
    <!DOCTYPE html>
    <html>
    <head><link rel="stylesheet" href="/static/xxx.css"></head>
    <body>
      <div id="app">加载中...</div>
  `);
  res.flush(); // 重要:强制发送第一个 chunk

  // 模拟长耗时业务 API
  const data = await fetchBusinessAPI();

  // 输出数据部分,前端可用 JS 接收并渲染
  res.write(`
      <script>
        window.__DATA__ = ${JSON.stringify(data)};
        // 或者直接渲染到 DOM
        document.getElementById('app').innerHTML = render(data);
      </script>
    </body>
    </html>
  `);
  res.end();
});
html
<!DOCTYPE html>
<html lang="">

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta http-equiv="Pragma" content="no-cache">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0,viewport-fit=cover">
  <script>
  </script>
  <title></title>
</head>
   <style>
      ._page-loading-wrapper {
        opacity: 1;
        position: fixed;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
        align-items: center;
        padding-bottom: 15%;
        justify-content: center;
        display: flex;
      }
      ._page-loading-content {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 65px;
        height: 65px;
        border-radius: 6px;
        background-color: rgba(0, 0, 0, 0.5);
      }
      .juhua-loading {
        position: relative;
        width: 40px;
        height: 40px;
      }
      .juhua-loading .jh-circle-ios {
        position: absolute;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
      }
      .juhua-loading .jh-circle-ios:before {
        content: '';
        display: block;
        margin: 5% auto;
        width: 8%;
        height: 25%;
        background-color: #fff;
        border-radius: 30%;
        -webkit-animation: jh-circleFadeDelay-ios 1.2s infinite ease-in-out both;
        animation: jh-circleFadeDelay-ios 1.2s infinite ease-in-out both;
      }
      .juhua-loading .jh-circle2 {
        -webkit-transform: rotate(30deg);
        -ms-transform: rotate(30deg);
        transform: rotate(30deg);
      }
      .juhua-loading .jh-circle3 {
        -webkit-transform: rotate(60deg);
        -ms-transform: rotate(60deg);
        transform: rotate(60deg);
      }
      .juhua-loading .jh-circle4 {
        -webkit-transform: rotate(90deg);
        -ms-transform: rotate(90deg);
        transform: rotate(90deg);
      }
      .juhua-loading .jh-circle5 {
        -webkit-transform: rotate(120deg);
        -ms-transform: rotate(120deg);
        transform: rotate(120deg);
      }
      .juhua-loading .jh-circle6 {
        -webkit-transform: rotate(150deg);
        -ms-transform: rotate(150deg);
        transform: rotate(150deg);
      }
      .juhua-loading .jh-circle7 {
        -webkit-transform: rotate(180deg);
        -ms-transform: rotate(180deg);
        transform: rotate(180deg);
      }
      .juhua-loading .jh-circle8 {
        -webkit-transform: rotate(210deg);
        -ms-transform: rotate(210deg);
        transform: rotate(210deg);
      }
      .juhua-loading .jh-circle9 {
        -webkit-transform: rotate(240deg);
        -ms-transform: rotate(240deg);
        transform: rotate(240deg);
      }
      .juhua-loading .jh-circle10 {
        -webkit-transform: rotate(270deg);
        -ms-transform: rotate(270deg);
        transform: rotate(270deg);
      }
      .juhua-loading .jh-circle11 {
        -webkit-transform: rotate(300deg);
        -ms-transform: rotate(300deg);
        transform: rotate(300deg);
      }
      .juhua-loading .jh-circle12 {
        -webkit-transform: rotate(330deg);
        -ms-transform: rotate(330deg);
        transform: rotate(330deg);
      }
      .juhua-loading .jh-circle2:before {
        -webkit-animation-delay: -1.1s;
        animation-delay: -1.1s;
      }
      .juhua-loading .jh-circle3:before {
        -webkit-animation-delay: -1s;
        animation-delay: -1s;
      }
      .juhua-loading .jh-circle4:before {
        -webkit-animation-delay: -0.9s;
        animation-delay: -0.9s;
      }
      .juhua-loading .jh-circle5:before {
        -webkit-animation-delay: -0.8s;
        animation-delay: -0.8s;
      }
      .juhua-loading .jh-circle6:before {
        -webkit-animation-delay: -0.7s;
        animation-delay: -0.7s;
      }
      .juhua-loading .jh-circle7:before {
        -webkit-animation-delay: -0.6s;
        animation-delay: -0.6s;
      }
      .juhua-loading .jh-circle8:before {
        -webkit-animation-delay: -0.5s;
        animation-delay: -0.5s;
      }
      .juhua-loading .jh-circle9:before {
        -webkit-animation-delay: -0.4s;
        animation-delay: -0.4s;
      }
      .juhua-loading .jh-circle10:before {
        -webkit-animation-delay: -0.3s;
        animation-delay: -0.3s;
      }
      .juhua-loading .jh-circle11:before {
        -webkit-animation-delay: -0.2s;
        animation-delay: -0.2s;
      }
      .juhua-loading .jh-circle12:before {
        -webkit-animation-delay: -0.1s;
        animation-delay: -0.1s;
      }
      @-webkit-keyframes jh-circleFadeDelay-ios {
        0%,
        39%,
        100% {
          opacity: 0.3;
        }
        40% {
          opacity: 1;
        }
      }
      @keyframes jh-circleFadeDelay-ios {
        0%,
        39%,
        100% {
          opacity: 0.3;
        }
        40% {
          opacity: 1;
        }
      }
    </style>
<body>
  <noscript>
    <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
        Please enable it to continue.</strong>
  </noscript>
  <div id="app">
        <div class="_page-loading-wrapper" id="appLoading">
        <div class="_page-loading-content">
          <div class="juhua-loading">
            <div class="jh-circle1 jh-circle-ios"></div>
            <div class="jh-circle2 jh-circle-ios"></div>
            <div class="jh-circle3 jh-circle-ios"></div>
            <div class="jh-circle4 jh-circle-ios"></div>
            <div class="jh-circle5 jh-circle-ios"></div>
            <div class="jh-circle6 jh-circle-ios"></div>
            <div class="jh-circle7 jh-circle-ios"></div>
            <div class="jh-circle8 jh-circle-ios"></div>
            <div class="jh-circle9 jh-circle-ios"></div>
            <div class="jh-circle10 jh-circle-ios"></div>
            <div class="jh-circle11 jh-circle-ios"></div>
            <div class="jh-circle12 jh-circle-ios"></div>
          </div>
        </div>
      </div>
  </div>
</body>

</html>

2.0 使用离线包

后续我们发现,只做如上优化,当用户网络不好,还是会出现白屏问题,导致用户等待时间过长,所以就想着能不能进一步的优化,所以用离线包的方式去解决。

我们当时做的方案只做了首页几个模块,一是首页的搜索模块,二是首页的每日签到模块。

初版方案是这样的

alt text

但是以上方案有几个问题

  • 离线包会增加下载量、占用磁盘空间,因为我们应用内很多模块
  • 在做 A B test,并不好做,需要额外实现,
  • 只有在冷启动时触发离线包的资源的下载,导致用户更新不及时
  • 一些边界的场景如要额外的处理(比如版本管理、增量/全量、校验、解压、原子替换、失败回退等)

总之一句话,我们的 app 不太想过多投入,所以这个方案其实做的 77 88 就不做了。于是有了 3.0 版本的方案。

3.0 使用预加载和预渲染

其实 webview 第一次是最慢的打开 H5 是最费时间。

平台第一次创建第二次创建
Android(WebView)100ms ~ 400ms(甚至更高)20ms ~ 80ms
iOS(WKWebView)50ms ~ 150ms10ms ~ 50ms

所以我们让客户端实现一个预加载预渲染的机制,通过拉取预渲染的配置,来实现开几个 webview。其目的有二

  • 提前渲染资源
  • 提前缓存资源
  • 提前创建 webview 以此来实现秒开效果

整体方案流程图如下所示

alt text

整体说明如下:

  • 启动 APP 时,拉取 web 页面加载配置信息,返回, preRender的属于预渲染的页面
json
{
  "H5Config": {
    "urls": [
      {
        "url": "https://www.baidu.com/baiduA/",
        "versionUrl": "https://www.baidu.com/baiduA/1_0_0.html",
        "preRender": true
      },
      {
        "url": "https://www.baidu.com/baiduB/",
        "versionUrl": "https://www.baidu.com/baiduB/1_0_0.html",
        "preRender": false
      },
    ]
  }
}
  • 保证资源被缓存,完全使用浏览器的缓存策略
yaml
// 全部资源都做缓存策略
Cache-Control: max-age=31536000
  • 如何解决,资源更新问题

定义 jsapi 方法,每次进入 H5页面是, H5 调用这个 API ,来获取最新的资源版本号,当用户重新进入时,保证每次进的都是ok的版本

  • 如何解决 a b test问题,因为仅仅是一个接口配置,所以配置跟新的很快。

其实我也看过一些其它的方案,一些大厂的方案看下来确实完美,但我觉得找到一个最合适自己公司的才是最好的。

其他

  • APP 对 webview 添加白名单机制,只有符合条件的域名才能调用 native 方法
  • 对扫码出来的符合跳转协议的标准,也做白名单机制,白名单机内才能通过扫码打开
  • 对页面设置 CSP 策略,可以在 Nginx 配置

相关文档