【译】页面生命周期API


现代浏览器有时在系统资源受限时,会出现页面卡死或完全丢弃页面的情况。 在未来,浏览器希望让用户可以消耗更少的资源和内存,更加积极主动地做这件事。页面生命周期 API,运行在 Chrome 68 中,提供了生命周期的钩子,你可以在不影响用户体验的情况下,安全的去处理浏览器响应。 接下来我们就详细的了解一下这个 API,看看你是否会在应用中尝试使用这些特性。

原文链接:https://developers.google.com/web/updates/2018/07/page-lifecycle-api

背景资料

现代操作系统管理资源的主要方式就是应用程序生命周期。 像在 AndroidiOS 和近期的 Windows 版本上,应用程序可以在任何时候被操作系统启动和终止。 平台能够针对用户操作,进行精简或重新分配资源。

在网页上,一直没有这样的生命周期,应用程序就会被无限期地保存下来。 但随着大量网页的运行,诸如内存、 CPU、电池和网络等关键系统资源被超额订阅,会导致非常糟糕的终端用户体验。

尽管网页长期以来都有与生命周期状态有关的事件—例如加载、卸载和可视化变化—但这些事件只允许开发人员响应用户发起的生命周期状态变化。为了使网络能够在低功率设备上可靠地工作(并且在所有平台上都有更多的资源意识) ,浏览器需要一种主动回收和重新分配系统资源的方法。

事实上,现在的浏览器已经采取了积极的措施来节省后台标签页面的资源,而且许多浏览器(特别是 Chrome)想要做更多的事情—来减少他们的整体资源足迹。

但问题是,开发者目前既不知道能去干预,也不知道怎样去这些系统发起干预措施。 同时, 也需要考虑到浏览器是要保守推进,还是冒着被破坏的风险。

页面生命周期 API 试图通过以下方式解决这个问题:

- 在网页上引入生命周期状态概念并使之标准化
- 定义新的系统启动状态,允许浏览器通过隐藏或不活跃来限制标签所消耗的资源
- 创建新的 API 和事件,使 web 开发人员能够对这些新系统启动状态的转换做出响应

这个解决方案提供了 web 开发者建立应用程序所需要的可预见性,它允许浏览器更积极地优化系统资源,最终使所有网络用户受益。

这篇文章的其余部分将介绍运行在 Chrome 68 浏览器中的新的页面生命周期特性,并探索它与所有现有的 web 平台状态和事件之间的关系。 也会为开发工作者提供在每个状态下操作的建议和最佳实践。

页面生命周期状态和事件概述

所有页面的生命周期状态是离散和相互独立的,这意味着一个页面同一时间只能处于一个状态。 对页面生命周期状态的大多数更改通常可以通过 DOM 事件来观察(请参阅开发者对每个状态的异常建议)。

也许使用图表是解释页面生命周期状态的最简单的方法——以及它们之间状态转换的事件:

这里写图片描述

状态


下表详细解释了每个状态。 它还列出了可能出现的状态,以及开发人员可以用来观察变化的事件。


状态名 描述
Active 如果页面是可见的并且有输入焦点,那么它就处于激活状态。

可能的上一个状态: passive (通过 focus 事件)
可能的下一个状态: passive (通过 blur 事件)
Passive 如果页面可见且没有输入焦点,则页面处于被动状态。

可能的先前状态: active (通过 blur 事件) hidden (通过 visibilitychange 事件)
可能的下一个状态: active (通过 focus 事件) hidden (通过 visibilitychange 事件)
Hidden 如果页面不可见且未被冻结,则页面处于隐藏状态。

可能的先前状态: passive 状态(通过visibilitychange事件)
可能的下一个状态: passive (通过visibilitychange事件) frozen (通过 freeze 事件) terminated (通过页面pagehide事件)
Frozen 在冻结状态下,浏览器将会暂停执行任务队列中的任务,直到页面解冻。 就像 JavaScript 的定时器一样,回调函数不会立即执行。 已经在运行的任务可以完成(一般主要指的是冻结回调) ,但是会在可以执行哪些方法, 可以执行多长时间上可能会受限制。
浏览器将页面冻结,是保存 CPU/电池/数据使用的一种方式; 同时,也是为了让浏览器可以快速的返回/跳转显示页面ーー避免全页面重新加载。

可能的先前状态: hidden(通过freeze事件)
可能的下一个状态: active(通过resume事件,然后 pageshow 事件)passive(通过resume事件,然后是 pageshow 事件)
Terminated 一旦浏览器开始卸载和清除内存,页面就处于终止状态。 在这种状态下,没有新的任务可以开始,如果进度任务跑得时间太久, 可能被杀死。

可能的先前状态: hidden(通过pagehide事件)
可能的下一个状态: 没有
Discarded 为了节约资源,当页面被浏览器卸载时,处于丢弃状态。 任何任务、事件回调或任何类型的 JavaScript 都不能在这种状态下运行,因为抛弃通常发生在资源约束下,在这种情况, 开始新的进程是不可能的。
在被丢弃的状态,即使页面已经消失,标签本身(包括标签标题和 favicon)还是是可见的。

可能的先前状态: frozen (没有事件发生)
可能的下一个状态: 没有


事件


浏览器分发了大量事件,但只有一小部分可能会引起页面生命周期状态发生变化。 下表概述了所有与生命周期相关的事件,并列出了它们转换过程。


名称 详情
focus 一个 DOM 元素已经收到了焦点。

> 注意:Focus 事件不一定意味着页面状态变化。只是会发出一个信号,说从没聚焦变为聚焦。
可能的先前状态: 被动
可能的当前状态: 活跃状态
focus 一个 DOM 元素已经收到了焦点。
注意:Focus 事件不一定意味着页面状态变化。只是会发出一个信号,说从没聚焦变为聚焦。

可能的先前状态: passive
可能的当前状态: active
blur 一个 DOM 元素已经失去了焦点。

> 注意: Blur 事件不一定意味着页面状态变化。 只是提醒说页面现在不处于聚焦状态了。(即页面没有从一个 focus 状态切换到另一个元素focus )

可能的先前状态: active
可能的当前状态: passive
visibilitychange 该文档的可视状态值发生改变。当用户打开一个新页面,切换标签,关闭标签,最小化或关闭浏览器,或者在移动操作系统上切换应用程序时,这种情况就会发生。

可能的先前状态: passive hidden
可能的当前状态: passive hidden
freeze * 该页面已经被冻结了。 页面任务队列中的任何可冻结任务都不会启动。

可能的先前状态: hidden
可能的当前状态: frozen
resume * 浏览器恢复了一个冻结页面。

可能的先前状态: frozen
可能的当前状态:
(1) active (如果后面跟着 pageshow 事件)
(2) passive (如果后面是 pageshow 事件)
(3) hidden
pageshow 会生成一条浏览器历史记录。
这可能是一个全新的页面加载,也可以是从浏览器缓存中获取的页面。 如果页面是从缓存中获取的,则该事件的 persisted 属性值为 true,否则为 false

可能的先前状态: frozen (也可能是 resume )
页面当前状态: active passive hidden
pagehide 页面隐藏,即将跳转。
如果用户打开另一个页面,浏览器可以将当前页添加到浏览器缓存内,以便稍后重用,那么该事件的 persisted 属性值为 true。 如果是 true,页面将进入 freeze,否则它将进入 terminated 状态。

可能的先前状态: hidden
可能的当前状态:
frozen (event.persistedtrue, 就是 freeze状态 )
terminated (event.persistedfalse, 触发 unload 事件)
beforeunload window, document 和页面内的资源可能会被卸载。 document 仍然是可见的, 而且可以取消该操作。

警告: beforeunload 只能提示用户没有保存的改变。 一旦改变已经被保存下来, 这个事件应该被删除。不能无条件的添加到页面中,会严重影响页面性能。详情请参阅 legacy APIs section

可能的先前状态: hidden
unload 页面正在被卸载。

警告: 任何时候都不建议使用 unload, 因为会影响页面性能。 详情请参阅 legacy APIs section

可能的先前状态: hidden
可能的当前状态: terminated


* 解释页面生命周期 API 定义的新事件 *

68 新增功能

上图中有两个状态是浏览器初始化形成的,而不是用户触发的: frozendiscarded。 像上述提到的一样,现在的浏览器会慎重的去选择冻结或者丢弃隐藏标签页, 但开发者并不知道他们什么时候发生。

Chrome 68 中,开发人员现在可以通过监听 freeze 和在文档上调用 resume 事件来观察隐藏的标签页何时被冻结, 被解冻。

document.addEventListener('freeze', (event) => {
  // The page is now frozen.
});
​
document.addEventListener('resume', (event) => {
  // The page has been unfrozen.
});


Chrome 68 中,文档对象现在也包含了一个 wasDiscarded 属性。 为了确定在隐藏选项卡中是否有页面被丢弃,您可以在页面加载时检查该属性的值(注意: 丢弃的页面必须重新加载才可以再次使用)。

if (document.wasDiscarded) {
  // Page was previously discarded by the browser while in a hidden tab.
}


关于在冻结和恢复事件中什么事情是重要的,以及如何处理和准备被丢弃的页面,请参阅开发者对每个状态的建议

接下来的几个部分概述这些新特性如何适应现有的网页平台的状态和事件。

用代码观察页面生命周期状态

activepassivehidden 状态中,运行 JavaScript 代码可以从现有的 Web 平台 Api 中确定当前页面的生命周期状态。

const getState = () => {
  if (document.visibilityState === 'hidden') {
    return 'hidden';
  }
  if (document.hasFocus()) {
    return 'active';
  }
  return 'passive';
};


另一方面,被冻结和终止的状态只能在它们各自的事件监听器(冻结和页面隐藏)中检测到。

观察状态变化

在上面定义的 getState 函数的基础上,您可以使用下面的代码观察所有页生命周期状态的变化。

// Stores the initial state using the `getState()` function (defined above).
let state = getState();
​
// Accepts a next state and, if there's been a state change, logs the
// change to the console. It also updates the `state` value defined above.
const logStateChange = (nextState) => {
  const prevState = state;
  if (nextState !== prevState) {
    console.log(`State change: ${prevState} >>> ${nextState}`);
    state = nextState;
  }
};
​
// These lifecycle events can all use the same listener to observe state
// changes (they call the `getState()` function to determine the next state).
['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach((type) => {
  window.addEventListener(type, () => logStateChange(getState()), {capture: true});
});
​
// The next two listeners, on the other hand, can determine the next
// state from the event itself.
window.addEventListener('freeze', () => {
  // In the freeze event, the next state is always frozen.
  logStateChange('frozen');
}, {capture: true});
​
window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // If the event's persisted property is `true` the page is about
    // to enter the page navigation cache, which is also in the frozen state.
    logStateChange('frozen');
  } else {
    // If the event's persisted property is not `true` the page is
    // about to be unloaded.
    logStateChange('terminated');
  }
}, {capture: true});
The above code does three things:


上面的代码可以做三件事:

- 用函数 getState() 初始化状态
- 定义一个函数接受下一个状态,如果有变化,则将状态更改记录打印到控制台
- 为所有必要的生命周期事件添加事件监听器,在状态变化时调用 logStateChange()

警告: 这段代码在不同的浏览器中产生不同的效果, 因为事件的执行顺序(和准确性)没有得到一致的实现。要学习如何最好地处理这些矛盾,请参见管理跨浏览器的差异.

关于上述代码需要注意的一点是,所有事件监听器会被添加到 window 中,而且设置了 {capture: true}。 原因有以下几点:

- 并不是所有的页面生命周期事件都有相同的目标源。pagehidepageshowwindows 上触发。visibilitychange, freezeresume 是在 document 上, focusblur 是在他们各自的 DOM 元素上触发。
- 大多数这些事件不会产生冒泡,这意味着不能将非捕获事件监听添加一个有共同祖先的元素上,去观察所有元素。
- 事件捕获和事件冒泡阶段在目标代码执行之前,因此添加监听器有助于可以在其他代码执行前,中断执行。

管理跨浏览器的差异


本文开头的图表根据页面生命周期 API 概述了状态和事件流。 但是由于这个 API 刚刚被引入,新的事件和 DOM API 还没有在所有的浏览器中实现。

此外,今天在所有浏览器中实现的事件也并没有统一实现方案。 例如:

- 当切换 Tab 的时候,一些浏览器是不触发 blur 的。也就意味着这个页面可以不经过 passive 状态,从 active 变为 hidden 状态。
- 一些浏览器完善了 页面导航缓存。 在页面生命周期 API 中定义缓存页面为 frozen 状态。
由于这个 API 是全新的,尽管通过 pagehidepageshow 事件可以触发这个状态,但有些浏览器还是没有实现 freezeresume 事件。

- IE10 以下的老版本浏览器,不支持 visibilitychange 事件。
- pagehidevisibilitychange 事件的执行顺序有被调整。如果页面处于可见状态,正打算被卸载,先前的浏览器是先出发 visibilitychange , 然后触发 pagehide。 新版本的 chrome, 则相反,不区分页面是否处于要卸载状态。

为了使开发人员更容易地处理这些跨浏览器的兼容性问题,专注于遵循生命周期状态推荐和最佳实践,我们发布了一个用于观察页面生命周期 API 状态变化的 JavaScript 库, PageLifecycle.js,。

PageLifecycle.js 将事件点击顺序中的跨浏览器差异规范化,使得在所有浏览器里,状态变化都和图表中的保持一致。

每个状态的开发推荐

作为开发人员,理解页面生命周期状态和知道如何在代码中观察它们是很重要的,因为你应该(而且不应该)做的工作在很大程度上取决于你的页面处于什么状态。

例如,如果页面处于隐藏状态,显然不能将暂时通知显示给用户。虽然这个例子很明显,但是还有其他一些建议不那么明显,但值得列举的。

状态 开发者建议
Active 对于用户来说,active 状态是最关键的时间,是响应用户输入的最重要时间

任何可能阻止主线程的非 UI工作应该被剥夺到空闲时间或者取消网络工作
Passive 在被动状态下,用户不与页面进行交互,但是他们仍然可以看到它。 这意味着用户界面的更新和动画应该仍然是平滑的,但是这些更新发生的时间并不那么重要。

当页面从主动变为被动时,这个时间最好是维持未保存的应用程序的状态。
Hidden 当页面从被动变为隐藏时,用户可能在重新加载之前不会再与它进行交互。

过渡到隐藏状态时, 通常也是开发人员可以观察到的最后一个状态变化(这在移动设备上更明显,因为用户可以关闭标签页或者浏览器应用程序本身,而且 beforeunloadpagehideunload 事件在这些情况下不会被触发)。

也意味着你应该将隐藏状态视为用户会话可能结束了。 换句话说,应该做到保留任何未保存的应用程序状态并发送任何未发送的分析数据。

你也应该停止更新用户界面(因为用户不会看到它们) ,停止任何用户不想在后台运行的任务。
Frozen 在冻结状态下,任务队列中自由执行的任务将暂停,直到页面解冻,也可能永远都不解冻(例如,如果页面被丢弃)。这意味着当页面从隐藏变为冻结时,你必须停止任何计时器或者关闭任何连接,这些连接如果被冻结,可能会影响到同一来源的其他打开的标签页,或者影响浏览器将页面导航缓存的功能。

尤其重要的是,你要:
- 关闭所有已经打开的 IndexedDB 连接
- 关闭所有 BroadcastChannel 连接
- 关闭已激活的WebRTC
- 停止所有网络轮询,关闭任何已打开的 Web Socket
- 释放所有 Web Locks

如果你还想在页面被抛弃或者重新加载后,恢复数据,那么您还应该坚持将任何动态视图状态(例如无限列表视图中的滚动位置)存储到 sessionStorage (或者通过(IndexedDB)提交

如果页面从冻结变为隐藏,您可以重新打开任何关闭连接或重新启动页面最初被冻结时停止的任何轮询。
Terminated 当页面转变为终止状态时,通常不需要采取任何操作。

由于用户操作而卸载的页面在进入终止状态之前,会经过隐藏状态,所以应该在隐藏状态中执行会话结束逻辑(如持续的应用状态和分析报告)。

同时(如对隐藏状态建议中提到的那样) ,开发人员必须认识到,在许多情况下(特别是在移动设备上)无法精准地检测到向终止状态的转换,因此依赖终止事件(如卸载、页面隐藏和卸载)的开发人员很可能会丢失数据。
Discarded 在页面被丢弃时,开发人员无法观察到丢弃的状态。 这是因为页面通常在资源约束下被丢弃,或者是页面解冻时,为了让页面能够响应或运行,丢弃一些在大多数情况下是不可能的执行的事件。

因此,你应该要有一个心理准备,在页面从隐藏到冷冻的状态变更中,会有被丢弃的可能性。然后你可以通过检查 document.wasDiscarded,在页面负载时对丢弃页面的恢复做出反应。


再次强调,由于在所有浏览器中生命周期事件的表现并不一致,所以上表中建议的最简单方法就是使用 PageLifecycle.js

生命周期 API 的遗留问题

unload 事件

关键点: 在现代浏览器中,永远不要使用 unload 事件.

许多开发人员将卸载事件视为可靠回调,并将其作为会话结束的信号来保存状态,发送分析数据,但是这样做是非常不可靠的,尤其是在移动端! 卸载事件不会在许多典型的卸载情况下启动,包括关闭切换标签或者关闭应用程序,切换程序。

出于这个原因,依赖visibilitychange事件来确定会话何时结束会更好,而且应该在隐藏状态时,作为保存应用程序和用户数据的最后一个可靠时间。

此外,仅仅存在已注册的卸载事件处理程序(通过 onununload 或 addEventListener ())可以阻止浏览器在页面导航缓存中添加页面,以便更快地返回和转发负载。

在所有现代浏览器(包括 IE11)中,建议始终使用页面隐藏事件来检测可能的页面卸载(又称终止状态) ,而不是卸载事件。 如果您需要支持 Internet Explorer 版本10及以下版本,您应该特别检测页面隐藏事件,并且只在浏览器不支持页面隐藏时使用卸载:

const terminationEvent = 'onpagehide' in self ? 'pagehide' : 'unload';
​
addEventListener(terminationEvent, (event) => {
  // Note: if the browser is able to cache the page, `event.persisted`
  // is `true`, and the state is frozen rather than terminated.
}, {capture: true});


有关页面导航缓存的更多信息,以及为什么卸载事件会影响页面导航缓存,请参见:

- WebKit Page Cache
- Firefox Back-Forward Cache Firefox

beforeunload 的事件

关键点: 永远不要无条件的使用 beforeunload 事件,作为页面会话结束的标志。只有在用户有未保存的工作的时候,添加它。当工作被保存后,立即移除。

beforeunload 事件与unload 事件有相似的问题,因为当它出现时,会阻止浏览器缓存页面

beforeunload 事件与unload 事件之间的区别在于,beforeunload 这个事件有合理的用途。 例如,当你想要警告用户,如果他们继续卸载页面,他们将失去未保存的更改。

由于在卸载之前使用是有正当场景的,但是使用它又会防止页面被添加到页面导航缓存中,因此建议您只在用户保存了未保存的更改后,才添加 beforeunload 事件监听器,然后在保存后立即删除。

换句话说,不要这样做(无条件地添加了beforeunload 事件监听器) :

addEventListener('beforeunload', (event) => {
  // A function that returns `true` if the page has unsaved changes.
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
}, {capture: true});


取而代之的是这样做(因为它只在需要的时候添加 beforeunload 事件监听器,当它不需要的时候移除它) :

const beforeUnloadListener = (event) => {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};
​
// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  addEventListener('beforeunload', beforeUnloadListener, {capture: true});
});
​
// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  removeEventListener('beforeunload', beforeUnloadListener, {capture: true});
});

注意: PageLifecycle.js 库提供了 unsavedchanges()removeUnsavedChanges() 方法,这些方法遵循了上面的原则,称得上是一个最佳实践。 它们是基于一个建议草案,以一个宣告性的 API 代替以前的 unload 事件,这样 API 不容易被滥用,在移动平台上更加可靠。

如果您想正确使用 beforeunload 事件,并且以跨浏览器的方式使用, 那么 PageLifecycle.js 库是我们推荐的最佳解决方案。

FAQs 常见问题

我的页面在隐藏的时候做了很重要的工作,我怎样才能阻止它被冻结或者丢弃呢?

网页在隐藏状态下运行时不应该被冻结有很多合理的理由。 最明显的例子是一个播放音乐的应用程序。

还有一些情况下,对于 Chrome 来说丢弃一个页面是有风险的,比如如果它包含一个没有提交的用户输入的表单,或者它有一个卸载处理程序,该处理程序会在页面卸载时发出警告。

目前,Chrome 在丢弃页面时会保守一些,只有当 Chrome 确信不会影响用户的时候才会这样做。 例如,在隐藏状态下被观察到做下列任何事情的页面不会被丢弃,除非在极端的资源限制下:

- 播放音频
- 使用 WebRTC
- 更新表标题或 favicon
- 显示警告
- 发送推送通知

注意: 对于更新标题或 favicon 以提醒用户未读通知的页面,我们目前有一个提议, 从service worker那里获得这些更新。这将允许 Chrome 冻结或丢弃页面,但仍然显示标签标题或 favicon 的更改。

页面导航缓存是什么
页面导航缓存是一个通用术语,用来描述一些浏览器实现的导航优化,使得使用前后按钮的速度更快。Webkit 称之为”页面缓存”,火狐称之为”后期缓存”(简称 bfcache)。

当用户从页面上移开时,这些浏览器会冻结该页面的版本,以便在用户使用后向或前进按钮的情况下迅速恢复该页面。 请记住,添加前卸载或卸载事件处理程序,可能会阻止此优化。

出于各种目的,这种冻结功能与冻结浏览器的行为来保护 cpu/电池的功能相同; 因此,它被认为是生命周期状态的一部分 — 冻结。

为什么没有提到加载或者 DOMContentLoaded 事件
页面生命周期 API 定义的状态是离散的和互斥的。 由于页面可以被加载到活动状态、被动状态或隐藏状态,所以单独的加载状态是没有意义的,因为加载和 DOMContentLoaded事件不会发出生命周期状态变化的信号,所以它们与这个 API 无关。

如果我不能在被冻结或终止的状态中运行异步API,我如何将数据保存到 IndexedDB
在冻结状态和终止状态中,页面任务队列中可被暂停,所以不能可靠地使用非同步和基于 callbackAPI,如 IndexedDB

在未来,我们将在 IDBTransaction 对象中添加一个 commit() 方法,这将给开发人员提供一种方法来执行那些不需要回调的只写事务。 换句话说,如果开发人员只是向 IndexedDB 编写数据,而不执行读和写组成的复杂事务,commit() 方法将能够在任务队列暂停之前的任务完成(假设 IndexedDB 数据库已经打开)。

然而,对如今目前的工作,开发人员有两个选择:
- 使用会话存储: 会话存储是同步的,并且是跨页丢弃是永久的
- service worker 中使用 IndexedDB: 在页面被终止和被抛弃之后,service worker 仍然还可以用来存储 IndexedDB 的数据。 在冻结和页面隐藏事件的监听器中,你可以通过 postMessage() 来发送数据到service workerservice worker 可以保存数据。

注意: 尽管上面的第二个选项可行,但在设备因为内存压力而冻结或丢弃页面的情况下,浏览器却不得不唤醒 service worker 的进程,这将给系统带来更大的压力。

在被冻结和丢弃的状态下测试你的应用程序

为了测试你的应用程序在被冻结和丢弃的状态下的表现,你可以访问 chrome://discard 来操作冻结或丢弃任何打开的标签。


Chrome Discards UI

通过判断 document.wasDiscarded 丢弃后,重新加载页面。这使您可以确保您的页面正确地处理冻结和恢复事件和文档。

总结


开发者如果想要尊重用户设备的系统资源,就应该在脑海中用页面生命周期状态来构建他们的应用程序。 网页不能在用户意想不到的情况下过多的消耗系统资源,这一点至关重要。

此外,越来越多的开发者开始补充新的页面生命周期 APIS,浏览器冻结和丢弃那些没有被使用的页面就越安全。 这意味着浏览器将消耗更少的内存、 CPU、电池和网络资源,这对用户来说是一件值得庆祝的事。

最后,开发人员如果想要实现本文描述的页面周期变化,但又不想记住所有可能的状态和事件转换过程,可以使用 PageLifecycle.js 来轻松地观察所有浏览器的生命周期状态变化。

猜你喜欢

转载自blog.csdn.net/YITA90/article/details/82499218