chiyan-lin / Blog

welcome gaidy Blog
6 stars 3 forks source link

hybrid app 内嵌 h5 性能优化 #1

Open chiyan-lin opened 4 years ago

chiyan-lin commented 4 years ago

前言

18年的时候,团队开始海外项目,受困于网络问题,经常被各方吐槽。测试说慢,产品说慢,老板一用也说慢。

于是加载性能就成了我们的一大 kpi ,用了3个 Q 的时间来做前期的数据收集,数据分析,方案制定,方案选择,以及最后的方案落地。

哪里慢了

先找原因,团队使用 nginx 作为静态文件服务器,并配置了常规的 CDN,同样的方式,在国内很正常。所以一定是运维和海外的问题(遇事先甩锅)

联系了运维,运维就是各种不接,需要我们找出来证据说明他们的链路有问题

于是我们花了大概两个月,收集数据以及制定指标

数据制定

关于前端技术指标,有很多,首次可交互时间,白屏时间,第一个有意义的帧等等

经过大佬的拍板,最终确定是用 FMP 作为性能指标,实现方式很简单暴力,直接在 new Vue 之后上报事件,作为 FMP 的数据点。(这里的上报点是有争议的,或者说这个根本就不是 FMP,而是 NVD【new vue done】,应该是要使用 Mutation Observer 检测节点挂载到app节点上才算)

  const t = performance.timing
  const navigationStart = t.navigationStart
  const now = Date.now()
  const fmp = performance.now ? Math.round(performance.now()) : (now - navigationStart)
const getRes = () => {
  let result = []
  let isHasHtml = false
  const performanceTime = performance.timing
  const performEntries = typeof performance.getEntries === 'function' ? performance.getEntries() : []
  const getReportData = (v, isResTiming = true) => {
    if (v.entryType === 'navigation') {
      isHasHtml = true
    }
    if (v.entryType === 'paint') return
    let obj = {
      redirect: v.redirectEnd - v.redirectStart,
      start: isResTiming ? v.startTime : (v.fetchStart - v.navigationStart),
      dnsLook: v.domainLookupEnd - v.domainLookupStart,
      tcp: v.connectEnd - v.connectStart,
      req: v.responseStart - v.requestStart,
      res: v.responseEnd - (v.responseStart || v.fetchStart),
      end: v.responseEnd - (isResTiming ? v.startTime : v.navigationStart),
      name: getName(v.name || (!isHasHtml && 'index.html') || 'unknow')
    }

    const isNavEntryType = isHTMLEntry(v, isResTiming)
    if (isNavEntryType) {
      obj.dom = Math.max(v.domInteractive - v.responseEnd, 0)
    }
    return cleanProps(obj)
  }
  performEntries.forEach(v => {
    const ret = getReportData(v)
    ret && result.push(ret)
  })
  // entries 里面找不到HTML页面的信息
  if (!isHasHtml) {
    result.push(getReportData(performanceTime, false))
  }
  return result
}

通过下面这个方法获取 cdn tcp 等数据

数据分析

我们以一个项目为样本得到了一下这些数据,一下 1.7s 这个线当时是大佬根据google提供的数据以及结合业务得出来的一个数据

时间 占比
< 1.7s 52.78%
1.7s ~ 3s 29.1%
> 3s 18.12%
指标 < 220ms 220ms ~ 1s > 1s
DNS 链接 66.46% 26.03 7.5
TCP 链接 77.26% 18.77 14.3

上述两个指标就是一个资源的 DNS 和 TCP 占用的时间占比了,运维提供说 TCP 创建220ms算正常(海外)

所以可以得出两个爆论:DNS解析慢;TCP链接过程也不怎么样

海外网络环境,网络基建没有我们做的好,资本主义总是有限照顾重要的地方,而我们的业务处理被白嫖阶段,有些偏小地方就没办法从运维层面来解决。

所以我们放弃幻想,从运维层去解决一无法提现我们技术性,二运维上推动起来进度慢,成本高(我们的业务是单独得到机器在运行,而且对于他们来说不重要),如何解决这个加载前夕的网络问题导致慢的问题,解决方案自然而让就是缓存,做最深度的缓存,把 html 都缓存起来,不走网络。

方案选择

webview 本身就有自己的一套缓存机制,但是满足不了 html 的缓存,我们的目标是静态页面不走网络(但是接口还是走的)。

1. service worker

作为 PWA 的核心功能,但从前端切入去做这个事情,第一个自然而然就是这个方法。

![service_worker_lifecycle.png](service_worker_lifecycle2.png

注册

html 入口第一次加载完之后,要注册一次 serviceWorker,

if ('serviceWorker' in navigator) {
    // 如果目前尚未有活跃的 SW ,那就直接安装并激活。
    // 如果已有 SW 安装着,向新的 swUrl 发起请求,获取内容和和已有的 SW 比较。如没有差别,则结束安装。
    // 如有差别,则安装新版本的 SW(执行 install 阶段),之后令其等待(进入 waiting 阶段)
    navigator.serviceWorker.register('./sw.js', {
        scope: string 
        // 设置 sw 的作用域,也就是路径 e.g '/a/b/c/' 注意设置的 scope 有可能出现作用域污染,存在多个 Service Worker 控制一个页面的情况呢
    })
  .then(reg => {
    // 会暴露出 sw 的属性和方法
    if(reg.installing) {
          console.log('Service worker installing');
        } else if(reg.waiting) {
          console.log('Service worker installed');
        } else if(reg.active) {
          console.log('Service worker active');
        }

    }).catch(function(error) {
        // registration failed
        console.log('Registration failed with ' + error);
    });
}

对于 SPA ,按上述方式注册即可,scope 填项目的路径

但对于多页面就见仁见智了,看看各个页面之间的耦合度如何

更新

项目的 Service Worker的更新问题,我们把 html 都缓存起来了,怎么更新我们的 sw.js 呢

  1. 不要给 service-worker.js 设置不同的名字,必须使用相同的名字
  2. 不要给 service-worker.js 设置缓存,设置 Cache-control: no-store (也就是 sw 每次都是走网络请求的)

由于浏览器的内部实现原理,当页面切换或者自身刷新时,浏览器是等到新的页面完成渲染之后再销毁旧的页面。这种做法比较温和,老的 SW 依然接管页面,新的 SW 依然在等待。等到新的 SW 完成,后续新的 SW 将接管当前页面

但是我们想马上让新的 SW 接管怎么玩呢 --- 插队=skipWaiting

在页面运行过程中,强制的 skipWaiting 会导致页面上老 SW ,被新 SW 直接取代,导致发生一些奇怪的问题(新 SW 会把老 SW 的预缓存干掉),最好是能保证两个页面的 SW 这种强制交替不会有问题发生。

所以更安全的方式是做一个提示,让用户手动更新,可以参考下 lavas

容灾

怎么关闭项目的 SW 呢?

可以维护一个项目的配置,在项目的配置中,增加配置项来决定 SW 的启用与关闭,在确定为关闭的情况下执行下面的代码注销掉项目项全部的 sw

navigator.serviceWorker.getRegistrations()
.then(regs => {
    for (let reg of regs) {
      // 注销掉所有的 Service Worker
      reg.unregister()
    }
})

缓存

设置项目的缓存list

self.addEventListener('install', function(event) {
  // sw的异步性,保证生命周期的执行顺序,在 install 之后才继续往下执行
  event.waitUntil(
    caches.open('v1').then(function(cache) {
      return cache.addAll([
        '/a/xx.js',
        '/a/index.html',
        '/a/style.css'
      ]);
    })
  );
});

// 监听请求,把缓存里面没有的都丢进缓存里面
self.addEventListener('fetch', function(event) {
  event.respondWith(caches.match(event.request).then(function(response) {
    // caches.match() 总是 resolves 没有命中上面缓存list的资源,会返回 undefined
    if (response !== undefined) {
      return response;
    } else {
      return fetch(event.request).then(function (response) {
        // 拦截请求 将资源缓存进cache,然后再返回缓存之后的东西
        let responseClone = response.clone();
        caches.open('v1').then(function (cache) {
          cache.put(event.request, responseClone);
        });
        return response;
      }).catch(function (e) {});
    }
  }));
});

后台更新 SW

后台的 SW 其实也是会定时地去拉取新的 SW 如果站点在浏览器后台长时间没有被刷新,则浏览器将自动检查更新,通常是每隔 24 小时检查一次,也可以在代码中手动触发更新

navigator.serviceWorker.register('/sw.js')
  .then(reg => {
    setInterval(() => {
      reg.update()
    }, 60 * 60 * 1000)
  })

以上就是 sw 的一些原理和注意项,作为实际项目使用的化还是需要更规范,这里还是建议使用 google 提供的 workbox 来定制策略和路由等东西。

2. 跨端实现前端包

这个需要客户端同学打头阵,两个路并行走

  1. 客户端启动 -> 拉取配置 -> 检查是否更新,否则结束 -> 下载zip包 -> 更新本地资源
  2. 用户打开web -> 客户端拦截 -> 检查是否需要使用前端包,否则走网络 -> 返回本地资源

目标就是实现一个高度依赖客户端的 SW ,但是不需要关系 SW 的兼容问题

过程1有一个定时器再轮训处理,大概是1个小时轮询一次,查看是否有更新的资源,结构如下

lifes.png

注册

前端包系统有一个配置平台,一开始需要上去配置下项目的基本信息以及是否需要开启前端包功能等

更新

  1. 客户端每次启动时检查更新
  2. 如果App一直打开,定时检查一次更新,时间由后台返回
  3. 客户端 webview 里面提供接口给H5页面,让H5可以触发更新检查,检查时机在每次进入页面的时候,触发一次检测更新

h5 的更新逻辑

const manifest = await ajax({ url: 'mainfest-hash.json' })
// 会返回是否已经更新
const result = await ajax({ url: 'CheckeVersionApi', data: { manifest.name, manifest.hash } })
if result.isUpdate === true
window.callApp('updatePkg')

项目发布过程的更新逻辑

容灾

一开始注册的地方可以选择是否走前端包还是网路的选项,配置成不走前端包项目在打开的时候就不走前端包了

缓存

function FeManifestPlugin (opts) {
  this.opts = Object.assign({
    opts
  }, opts || {})
}

FeManifestPlugin.prototype = {
  constructor: FeManifestPlugin,
  // webpack插件默认调用apply方法
  apply (compiler) {
    if (process.env.NODE_ENV !== 'production') return
    // webpack编译完成后的回调
    const doneHook = async compilation => {
      const res = compilation.toJson()
      this.assets = res.assets
      // 获取设置的 outputPath 和 publicPath ,预处理this.asset,增加this.fullPath
      this.normalizeAsset()
      // 修改 manifest.json 生成资源的 manifest 列表文件 { resources: [] }, 获取项目的 js css html 资源
      this.generateManifest(name)
      // 打包output目录
      const { zipFilePath } = await this.zipFolder()
      // 修改 manifest-hash.json 中的zip字段为false,再写到文件里面去。 非zip包里面的manifset-hash.json文件为false。
      generateFile(manifestHashJson, MANIFEST_HASH_FILE_NAME)
      // 文件上传至 CDN 并进行预热,oss 有预热的功能
      const cdn = new Cdn({ name, zipFilePath })
      await cdn.uploadFile().hot()
      // 生成最终的 cache.json, 这个用于单个项目的主要缓存项
      generateFile()
    }
    // wenpack4+
    compiler.hooks.done.tap('FeManifestPlugin', doneHook)
  }
}

上述简要的 webpack 伪代码,产出了三个文件

interface Project {
    zip: string;
    length: number;
    name: string;
    version: string;
    zipVersion: string;
}

后端接口实现及客户端实现

后端实现的部分主要有

interface ResProject extend Project {
    priority: number;
}

interface Res {
    refreshTime: number;
    projects: []Project;
}

客户端实现的前端包下载及打开

    @Override
    public boolean onLoadInterceptor() {
        //如果前端包配置没有加载,等配置加载完成后,再做接下来的处理;
        if (!mConfigManager.hasLoaded()) {
            loadUrl = url;
        } else if (mConfigManager.hasLoaded()) {
            //如果前端包配置已加载,赋值相关统计;上报
        }
        //异常处理:不拦截
        if (
            StringUtils.isEmpty(url) ||
            mConfigManager == null || 
            SettingFlags.getBoolean(KEY_WEBVIEW_INTEREPT)
        ) {
            return false;
        }
        //配置配了拦截:下载后,再打开网页
        ProjectConfigItem item = mConfigManager.isUrlInForceLoad(url);
        // 没下载成功,不拦截,所以下载失败就慢的可怕了
        if (item == null) {
            return false;
        }
        boolean intercepted = false;
        //已经下载了对应的资源直接打开
        if (!MyFileUtil.isPreloaded(item)) {
            // 超时处理,如果8秒还没有下载完离线资源;不再等待直接打开网页
            TaskExecutor.postToMainThread(new Runnable() {
            }, 8000);
        } else {}
        return intercepted;
    }

方案确定

海外的用户,特别是当前业务面对的用户群,受众相对使用比较多的是性能中下的普通机,对 sw 的支持度有限。

另外对于业务app来说,嵌入webview的方式,使用 sw 这种高级功能,客户端可能会产生奔溃等不可遇因素,然后客户端就会把锅扣过来了。客户端在多线程运行的情况支持相对较低,webview进程及客户端主进程没有分离,各个线程和进程的混杂,不好处理。

自实现的前端包对于业务来说,可控性更高,对于个人能力,跨部门协调的能力也是一个挑战。

于是前端包的方案就正式落地开始工作。

启动前端包的效果

时间 占比
< 1.7s 87.43%
1.7s ~ 3s 8.65%
> 3s 3.92%
指标 < 220ms 220ms ~ 1s > 1s
DNS 链接 94.3% 4.2 1.5
TCP 链接 92.8% 5.3 1.2

可以看到使用前端包之后,DNS解析较优提升28%左右,TCP链接较优提升了15%,FMP指标较优的提升了35%。

因为没有了网络请求,所以 tcp 和 dns 的时间都是缩小的,不对,其实应该都是0,应该是有些没走缓存走了网路。

性能因素就从网络方面转到了 html 的加载和 js 的执行时间上了,只需要在业务上的几个关键点加上一些数据眼,再进行数据统计

 获取 dom 挂载时间

 const timing = performance.timing
 const now = Date.now()
 // 如果上报在执行的时候dom还在渲染,则domInteractive取到是0,可以直接用当前时间
 const domInteractive = timing.domInteractive ? timing.domInteractive : now
 // 4.4.2以下系统responseEnd 为0的时候后使用当前时间,避免出现超大数出现。
 const dom = Math.max(domInteractive - (timing.responseEnd || now), 0).toFixed(0)

遇到的问题


前端感官性能的衡量和优化实践

以用户为中心的性能指标

FMP的智能获取算法

serviceworker-webpack-plugin

workbox

谨慎处理 Service Worker 的更新

service-worker-debug

资源请求响应策略

lavas

什么是 PWA