持续创造,加速生长!这是我参加「日新方案 6 月更文应战」的第1天,点击查看活动详情

最近在 WKWebView 中展示三维图画烘托的功能时,经常遇到 WKWebView 不可思议的 reload 的现象。

WKWebView 会在 APP 的进程外履行其所有的作业,并且 WKWebView 的内存运用量与 APP 的内存运用量分开核算。这样当 WKWebView 进程超出其内存约束时,就不会导致 APP 程序停止,最多也便是导致空白视图。

为了定位详细的原因,先查看一下 WKWebView 的署理提供的回调办法。在 WKNavigationDelegate 中界说了回调办法 webViewWebContentProcessDidTerminate(_)

/** @abstract Invoked when the web view's web content process is terminated.
@param webView The web view whose underlying web content process was terminated.
*/
@available(iOS 9.0, *)
optional func webViewWebContentProcessDidTerminate(_ webView: WKWebView)

当 web 视图的内容进程停止时,将经过此回调通知 APP,但是并没有提供更多的错误信息。

能够经过 github.com/WebKit/WebK… 看到 WKWebView 的源码。经过搜索 webViewWebContentProcessDidTerminate的办法,能够一步步知道 WKWebView 的反常流程。

WKWebView 进程反常的流程

WKWebView 的停止署理流程

在 WebKit 的 NavigationState.mm 文件中,调用了 webViewWebContentProcessDidTerminate 办法:

bool NavigationState::NavigationClient::processDidTerminate(WebPageProxy& page, ProcessTerminationReason reason)
{
  if (!m_navigationState)
    return false;
  if (!m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminate
    && !m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason
    && !m_navigationState->m_navigationDelegateMethods.webViewWebProcessDidCrash)
    return false;
  auto navigationDelegate = m_navigationState->m_navigationDelegate.get();
  if (!navigationDelegate)
    return false;
  if (m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason) {
    [static_cast<id <WKNavigationDelegatePrivate>>(navigationDelegate.get()) _webView:m_navigationState->m_webView webContentProcessDidTerminateWithReason:wkProcessTerminationReason(reason)];
    return true;
  }
  // We prefer webViewWebContentProcessDidTerminate: over _webViewWebProcessDidCrash:.
  if (m_navigationState->m_navigationDelegateMethods.webViewWebContentProcessDidTerminate) {
    [navigationDelegate webViewWebContentProcessDidTerminate:m_navigationState->m_webView];
    return true;
  }
  ASSERT(m_navigationState->m_navigationDelegateMethods.webViewWebProcessDidCrash);
  [static_cast<id <WKNavigationDelegatePrivate>>(navigationDelegate.get()) _webViewWebProcessDidCrash:m_navigationState->m_webView];
  return true;
}

processDidTerminate() 办法中,当线程停止时的处理流程为:

  • 若未设置署理办法,则回来 false;
  • 假如署理完成了 _webView:webViewWebContentProcessDidTerminateWithReason:,则回调,并回来 true;
  • 假如署理完成了 webViewWebContentProcessDidTerminate:,则回调,并回来 true;
  • 调用回调办法:_webViewWebProcessDidCrash:,并回来 true。

署理办法的设置办法如下:

void NavigationState::setNavigationDelegate(id <WKNavigationDelegate> delegate)
{
  // ....
  m_navigationDelegateMethods.webViewWebContentProcessDidTerminate = [delegate respondsToSelector:@selector(webViewWebContentProcessDidTerminate:)];
  m_navigationDelegateMethods.webViewWebContentProcessDidTerminateWithReason = [delegate respondsToSelector:@selector(_webView:webContentProcessDidTerminateWithReason:)];
  // ....
}

processDidTerminate() 办法中,参数 reason说明晰反常原因,类型为 ProcessTerminationReason,界说如下:

enum class ProcessTerminationReason {
  ExceededMemoryLimit, // 超出内存约束
  ExceededCPULimit,  // 超出CPU约束
  RequestedByClient,  // 自动触发的terminate
  IdleExit,
  Unresponsive,    // 无法呼应
  Crash,        // web进程自己发生了crash
  // Those below only relevant for the WebContent process.
  ExceededProcessCountLimit,
  NavigationSwap,
  RequestedByNetworkProcess,
  RequestedByGPUProcess
};

经过署理办法获取反常原因

能够看到回调办法有两个:webViewWebContentProcessDidTerminate:_webView:webContentProcessDidTerminateWithReason:,一个不带 reason 参数,一个带有 reason 参数,并且带有 reason 参数的回调办法优先级更高。

在 WKWebView 的 WKNavigationDelegate 署理中,咱们只看到了不带 reason 的回调办法,那 _webView:webContentProcessDidTerminateWithReason: 是怎么回事呢?

经过检索发现,它界说在 WKNavigationDelegatePrivate 在署理中:

@protocol WKNavigationDelegatePrivate <WKNavigationDelegate>
@optional
// ...
- (void)_webView:(WKWebView *)webView webContentProcessDidTerminateWithReason:(_WKProcessTerminationReason)reason WK_API_AVAILABLE(macos(10.14), ios(12.0));
// ...
@end

WKNavigationDelegatePrivate 并没有公开让 App 运用。不过,咱们依然能够经过完成上面的署理办法,获取到 reason 信息。

不过需求留意:WebKit 内部的反常类型为:ProcessTerminationReason,而此处 reason 参数的类型为:_WKProcessTerminationReason

typedef NS_ENUM(NSInteger, _WKProcessTerminationReason) {
    _WKProcessTerminationReasonExceededMemoryLimit,
    _WKProcessTerminationReasonExceededCPULimit,
    _WKProcessTerminationReasonRequestedByClient,
    _WKProcessTerminationReasonCrash,
} WK_API_AVAILABLE(macos(10.14), ios(12.0));

_WKProcessTerminationReasonProcessTerminationReason 的转化联系如下:

static _WKProcessTerminationReason wkProcessTerminationReason(ProcessTerminationReason reason)
{
    switch (reason) {
    case ProcessTerminationReason::ExceededMemoryLimit:
        return _WKProcessTerminationReasonExceededMemoryLimit;
    case ProcessTerminationReason::ExceededCPULimit:
        return _WKProcessTerminationReasonExceededCPULimit;
    case ProcessTerminationReason::NavigationSwap:
    case ProcessTerminationReason::IdleExit:
        // We probably shouldn't bother coming up with a new API type for process-swapping.
        // "Requested by client" seems like the best match for existing types.
        FALLTHROUGH;
    case ProcessTerminationReason::RequestedByClient:
        return _WKProcessTerminationReasonRequestedByClient;
    case ProcessTerminationReason::ExceededProcessCountLimit:
    case ProcessTerminationReason::Unresponsive:
    case ProcessTerminationReason::RequestedByNetworkProcess:
    case ProcessTerminationReason::RequestedByGPUProcess:
    case ProcessTerminationReason::Crash:
        return _WKProcessTerminationReasonCrash;
    }
    ASSERT_NOT_REACHED();
    return _WKProcessTerminationReasonCrash;
}

能够看出,在转化过程中,并不是一一对应的,会损失掉详细的 crash 类型。也便是,当咱们完成_webView:webContentProcessDidTerminateWithReason:署理时,能够获取到一个相对抽象的 reason。

内存超限的逻辑(ExceededMemoryLimit)

下面先剖析内存超限的逻辑。

初始化 web 线程的办法:initializeWebProcess(),完成如下:

void WebProcess::initializeWebProcess(WebProcessCreationParameters&& parameters)
{  
  // ...
  if (!m_suppressMemoryPressureHandler) {
    // ...
    #if ENABLE(PERIODIC_MEMORY_MONITOR)
    memoryPressureHandler.setShouldUsePeriodicMemoryMonitor(true);
    memoryPressureHandler.setMemoryKillCallback([this] () {
      WebCore::logMemoryStatistics(LogMemoryStatisticsReason::OutOfMemoryDeath);
      if (MemoryPressureHandler::singleton().processState() == WebsamProcessState::Active)
        parentProcessConnection()->send(Messages::WebProcessProxy::DidExceedActiveMemoryLimit(), 0);
      else
        parentProcessConnection()->send(Messages::WebProcessProxy::DidExceedInactiveMemoryLimit(), 0);
    });
    // ...
    #endif
    // ...
  }
  // ...
}

其间:

  • setShouldUsePeriodicMemoryMonitor() 设置是否需求定时检测内存;
  • setMemoryKillCallback() 设置内存超限后,被停止后的回调。

定时内存检测

设置定时内存检测的办法setShouldUsePeriodicMemoryMonitor的完成如下:

void MemoryPressureHandler::setShouldUsePeriodicMemoryMonitor(bool use)
{
  if (!isFastMallocEnabled()) {
    // If we're running with FastMalloc disabled, some kind of testing or debugging is probably happening.
    // Let's be nice and not enable the memory kill mechanism.
    return;
  }
  if (use) {
    m_measurementTimer = makeUnique<RunLoop::Timer<MemoryPressureHandler>>(RunLoop::main(), this, &MemoryPressureHandler::measurementTimerFired);
    m_measurementTimer->startRepeating(m_configuration.pollInterval);
  } else
    m_measurementTimer = nullptr;
}

其间,初始化了一个 Timer,时刻距离为 m_configuration.pollInterval(pollInterval 的值为 30s),履行办法为 measurementTimerFired()。也便是每隔 30s 调用一次 measurementTimerFired() 对内存运用量进行一次查看。

内存查看的办法 measurementTimerFired() 界说如下:

void MemoryPressureHandler::measurementTimerFired()
{
  size_t footprint = memoryFootprint();
#if PLATFORM(COCOA)
  RELEASE_LOG(MemoryPressure, "Current memory footprint: %zu MB", footprint / MB);
#endif
  auto killThreshold = thresholdForMemoryKill();
  if (killThreshold && footprint >= *killThreshold) {
    shrinkOrDie(*killThreshold);
    return;
  }
  setMemoryUsagePolicyBasedOnFootprint(footprint);
  switch (m_memoryUsagePolicy) {
  case MemoryUsagePolicy::Unrestricted:
    break;
  case MemoryUsagePolicy::Conservative:
    releaseMemory(Critical::No, Synchronous::No);
    break;
  case MemoryUsagePolicy::Strict:
    releaseMemory(Critical::Yes, Synchronous::No);
    break;
  }
  if (processState() == WebsamProcessState::Active && footprint > thresholdForMemoryKillOfInactiveProcess(m_pageCount))
    doesExceedInactiveLimitWhileActive();
  else
    doesNotExceedInactiveLimitWhileActive();
}

其间,footprint来为当时运用的内存量,killThreshold为内存的最大约束。假如 killThreshold 大于等于 footprint,则调用 shrinkOrDie()

当时运用的内存量

当时运用的内存量是经过 memoryFootprint()来获取的。界说如下:

namespace WTF {
size_t memoryFootprint()
{
  task_vm_info_data_t vmInfo;
  mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
  kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
  if (result != KERN_SUCCESS)
    return 0;
  return static_cast<size_t>(vmInfo.phys_footprint);
}
}

其间,运用到了 task_info来获取线程的信息,传递的参数有:

  • mach_task_self() :获取当时进程
  • TASK_VM_INFO:取虚拟内存信息
  • vmInfo、count:两个参数传递的为引用地址,用于接收回来值。
    • vmInfo:task_vm_info_data_t 里的 phys_footprint 便是进程的内存占用,以 byte 为单位。

内存的最大约束

内存最大的约束由 thresholdForMemoryKill() 办法完成,界说如下:

std::optional<size_t> MemoryPressureHandler::thresholdForMemoryKill()
{
  if (m_configuration.killThresholdFraction)
    return m_configuration.baseThreshold * (*m_configuration.killThresholdFraction);
  switch (m_processState) {
  case WebsamProcessState::Inactive:
    return thresholdForMemoryKillOfInactiveProcess(m_pageCount);
  case WebsamProcessState::Active:
    return thresholdForMemoryKillOfActiveProcess(m_pageCount);
  }
  return std::nullopt;
}
static size_t thresholdForMemoryKillOfActiveProcess(unsigned tabCount)
{
  size_t baseThreshold = ramSize() > 16 * GB ? 15 * GB : 7 * GB;
  return baseThreshold + tabCount * GB;
}
static size_t thresholdForMemoryKillOfInactiveProcess(unsigned tabCount)
{
#if CPU(X86_64) || CPU(ARM64)
  size_t baseThreshold = 3 * GB + tabCount * GB;
#else
  size_t baseThreshold = tabCount > 1 ? 3 * GB : 2 * GB;
#endif
  return std::min(baseThreshold, static_cast<size_t>(ramSize() * 0.9));
}

能够看出,最大的可用内存由:当时的 webview 的页数(m_pageCount),线程的状况(Inactive 和 Active)和 ramSize() 核算得来。

当时的页数和线程的状况比较易容了解,下面来看 ramSize() 的核算办法:

namespace WTF {
size_t ramSize()
{
  static size_t ramSize;
  static std::once_flag onceFlag;
  std::call_once(onceFlag, [] {
    ramSize = computeRAMSize();
  });
  return ramSize;
}
} // namespace WTF

ramSize 只会核算一次,由 computeRAMSize() 核算得来,界说如下:

#if OS(WINDOWS)
static constexpr size_t ramSizeGuess = 512 * MB;
#endif
static size_t computeRAMSize()
{
#if OS(WINDOWS)
  MEMORYSTATUSEX status;
  status.dwLength = sizeof(status);
  bool result = GlobalMemoryStatusEx(&status);
  if (!result)
    return ramSizeGuess;
  return status.ullTotalPhys;
#elif USE(SYSTEM_MALLOC)
#if OS(LINUX) || OS(FREEBSD)
  struct sysinfo si;
  sysinfo(&si);
  return si.totalram * si.mem_unit;
#elif OS(UNIX)
  long pages = sysconf(_SC_PHYS_PAGES);
  long pageSize = sysconf(_SC_PAGE_SIZE);
  return pages * pageSize;
#else
#error "Missing a platform specific way of determining the available RAM"
#endif // OS(LINUX) || OS(FREEBSD) || OS(UNIX)
#else
  return bmalloc::api::availableMemory();
#endif
}

computeRAMSize() 中,依据不同的操作体系(Windows,LINUX、Unix)和一个默许方式来核算。需求留意的是:虽然iOS是基于 Unix 的,但是这儿的 Unix 不包括 iOS 体系。所以,在 iOS 体系中,会履行 return bmalloc::api::availableMemory();。界说如下:

inline size_t availableMemory()
{
  return bmalloc::availableMemory();
}

它仅仅简略的调用了 bmalloc::availableMemory()。再来看 bmalloc::availableMemory() 的完成:

size_t availableMemory()
{
  static size_t availableMemory;
  static std::once_flag onceFlag;
  std::call_once(onceFlag, [] {
    availableMemory = computeAvailableMemory();
  });
  return availableMemory;
}

availableMemory()办法中的 availableMemory只会核算一次,由 computeAvailableMemory()核算而来。

static constexpr size_t availableMemoryGuess = 512 * bmalloc::MB;
static size_t computeAvailableMemory()
{
#if BOS(DARWIN)
  size_t sizeAccordingToKernel = memorySizeAccordingToKernel();
#if BPLATFORM(IOS_FAMILY)
  sizeAccordingToKernel = std::min(sizeAccordingToKernel, jetsamLimit());
#endif
  size_t multiple = 128 * bmalloc::MB;
  // Round up the memory size to a multiple of 128MB because max_mem may not be exactly 512MB
  // (for example) and we have code that depends on those boundaries.
  return ((sizeAccordingToKernel + multiple - 1) / multiple) * multiple;
#elif BOS(FREEBSD) || BOS(LINUX)
  //...
#elif BOS(UNIX)
  //...
#else
  return availableMemoryGuess;
#endif
}

computeAvailableMemory()办法中,

  1. 先经过 memorySizeAccordingToKernel() 获取内核的内存大小;
  2. 假如是 iOS 体系,再获取 jetsam 的约束:jetsamLimit(),在内存大小和 jetsamLimit() 中取较小的值;
  3. 将成果向上取整为 128M 的倍数。

所以,此处的成果依赖于 memorySizeAccordingToKernel()jetsamLimit()

先看 memorySizeAccordingToKernel()的完成:

#if BOS(DARWIN)
static size_t memorySizeAccordingToKernel()
{
#if BPLATFORM(IOS_FAMILY_SIMULATOR)
  BUNUSED_PARAM(availableMemoryGuess);
  // Pretend we have 1024MB of memory to make cache sizes behave like on device.
  return 1024 * bmalloc::MB;
#else
  host_basic_info_data_t hostInfo;
  mach_port_t host = mach_host_self();
  mach_msg_type_number_t count = HOST_BASIC_INFO_COUNT;
  kern_return_t r = host_info(host, HOST_BASIC_INFO, (host_info_t)&hostInfo, &count);
  mach_port_deallocate(mach_task_self(), host);
  if (r != KERN_SUCCESS)
    return availableMemoryGuess;
  if (hostInfo.max_mem > std::numeric_limits<size_t>::max())
    return std::numeric_limits<size_t>::max();
  return static_cast<size_t>(hostInfo.max_mem);
#endif
}
#endif

逻辑为:

  1. 假如是模拟器,则内存设定为 1024M。
  2. 假如是实在设备,则经过 host_info获取结构体为 host_basic_info_data_t 的信息,读取 max_mem的数值,然后与 std::numeric_limits<size_t>::max()进行比较,取其间较小的值。其间 std::numeric_limits<size_t>::max() 为当时设备能够表明的最大值。
  3. 核算失利时,回来 availableMemoryGuess,即 512 M。

再来看jetsamLimit()的完成:

#if BPLATFORM(IOS_FAMILY)
static size_t jetsamLimit()
{
  memorystatus_memlimit_properties_t properties;
  pid_t pid = getpid();
  if (memorystatus_control(MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES, pid, 0, &properties, sizeof(properties)))
    return 840 * bmalloc::MB;
  if (properties.memlimit_active < 0)
    return std::numeric_limits<size_t>::max();
  return static_cast<size_t>(properties.memlimit_active) * bmalloc::MB;
}
#endif

jetsamLimit()中,

  1. 经过 memorystatus_control() 获取结构体为 memorystatus_memlimit_properties_t 的信息,回来值不为 0,则回来 840M;
  2. 假如获取的 memoryStatus 的约束属性 memlimit_active 小于 0 时,则回来当时设备能够表明的最大值;
  3. 假如运转正常,则回来体系回来的数值。

至此,就看到了 ramSize() 的整个核算过程。

总结一下内存最大约束的核算办法:

  1. 判别线程当时的状况:
    1. 激活状况
      1. 核算 ramSize();
        1. 核算内核的内存大小和 jetsam 的约束,取较小值,
        2. 向上取整为 128M 的倍数。
      2. 核算 baseThreshold = ramSize() > 16 * GB ? 15 * GB : 7 * GB;
      3. 最终成果为:baseThreshold + tabCount * GB;
    2. 非激活状况:
      1. CPU(X86_64) || CPU(ARM64) 下,baseThreshold = 3 * GB + tabCount * GB;,不然 baseThreshold = tabCount > 1 ? 3 * GB : 2 * GB;
      2. 最终成果为:smin(baseThreshold, (ramSize() * 0.9))

内存超限的处理

内存超限之后,就会调用 shrinkOrDie(),界说如下:

void MemoryPressureHandler::shrinkOrDie(size_t killThreshold)
{
  RELEASE_LOG(MemoryPressure, "Process is above the memory kill threshold. Trying to shrink down.");
  releaseMemory(Critical::Yes, Synchronous::Yes);
  size_t footprint = memoryFootprint();
  RELEASE_LOG(MemoryPressure, "New memory footprint: %zu MB", footprint / MB);
  if (footprint < killThreshold) {
    RELEASE_LOG(MemoryPressure, "Shrank below memory kill threshold. Process gets to live.");
    setMemoryUsagePolicyBasedOnFootprint(footprint);
    return;
  }
  WTFLogAlways("Unable to shrink memory footprint of process (%zu MB) below the kill thresold (%zu MB). Killed\n", footprint / MB, killThreshold / MB);
  RELEASE_ASSERT(m_memoryKillCallback);
  m_memoryKillCallback();
}

其间,m_memoryKillCallback 便是在初始化 web 线程时设置的回调。

因为 OOM 导致 reload/白屏,看起来并不是iOS的机制。从办法的调用联系进行全局检索,目前发现内存超出导致的白屏只要这么一条调用链。

OOM 之后的默许处理流程

苹果对 WebContentProcessDidTerminate 的处理逻辑如下:

void WebPageProxy::dispatchProcessDidTerminate(ProcessTerminationReason reason)
{
  bool handledByClient = false;
  if (m_loaderClient)
    handledByClient = reason != ProcessTerminationReason::RequestedByClient && m_loaderClient->processDidCrash(***this**);
  else
    handledByClient = m_navigationClient->processDidTerminate(*this, reason);
  if (!handledByClient && shouldReloadAfterProcessTermination(reason)) {
    // We delay the view reload until it becomes visible.
    if (isViewVisible())
      tryReloadAfterProcessTermination();
    else {
      WEBPAGEPROXY_RELEASE_LOG_ERROR(Loading, "dispatchProcessDidTerminate: Not eagerly reloading the view because it is not currently visible");
      m_shouldReloadDueToCrashWhenVisible = true;
    }
  }
}

其间 m_loaderClient 只在苹果的单元测试中有运用,所以,正式版本的 iOS 下应该会履行:

handledByClient = m_navigationClient->processDidTerminate(*this, reason);

假如开发者未完成 webViewWebContentProcessDidTerminate(_) 的署理办法,将回来 false,进入苹果的默许处理逻辑:经过 shouldReloadAfterProcessTermination() 判别是否需求进行从头加载,假如需求则在恰当时候进行从头加载。

shouldReloadAfterProcessTermination() 依据停止原因来判别是否需求进行从头加载:

static bool shouldReloadAfterProcessTermination(ProcessTerminationReason reason)
{
    switch (reason) {
    case ProcessTerminationReason::ExceededMemoryLimit:
    case ProcessTerminationReason::ExceededCPULimit:
    case ProcessTerminationReason::RequestedByNetworkProcess:
    case ProcessTerminationReason::RequestedByGPUProcess:
    case ProcessTerminationReason::Crash:
    case ProcessTerminationReason::Unresponsive:
        return true;
    case ProcessTerminationReason::ExceededProcessCountLimit:
    case ProcessTerminationReason::NavigationSwap:
    case ProcessTerminationReason::IdleExit:
    case ProcessTerminationReason::RequestedByClient:
        break;
    }
    return false;
}

tryReloadAfterProcessTermination() 的改写逻辑如下:

static unsigned maximumWebProcessRelaunchAttempts = 1;
void WebPageProxy::tryReloadAfterProcessTermination()
{
  m_resetRecentCrashCountTimer.stop();
  if (++m_recentCrashCount > maximumWebProcessRelaunchAttempts) {
    WEBPAGEPROXY_RELEASE_LOG_ERROR(Process, "tryReloadAfterProcessTermination: process crashed and the client did not handle it, not reloading the page because we reached the maximum number of attempts");
    m_recentCrashCount = 0;
    return;
  }
  WEBPAGEPROXY_RELEASE_LOG(Process, "tryReloadAfterProcessTermination: process crashed and the client did not handle it, reloading the page");
  reload(ReloadOption::ExpiredOnly);
}

每次 crash 后,苹果会给 crash 标识(m_recentCrashCount)进行 +1,在不超越最大约束(maximumWebProcessRelaunchAttempts = 1)时,体系会进行改写,当最近 crash 的次数超越约束时,它便不会改写,仅仅将标明归位为0,下次就能够改写。

总结一下:假如开发者未完成 webViewWebContentProcessDidTerminate(_) 的署理办法:

  1. 则依据 crash 的原因判别是否要从头改写;
  2. 从头改写有最大次数约束(一次),超越则不会进行改写。

后记:咱们在iOS的Safari上测试了safari的白屏处理逻辑,当第一次发生白屏时Safari会默许重刷,第2次时safari会展示错误加载页,提示当时页面屡次发生了错误。这个逻辑和上面webkit的默许处理逻辑时类似的。

  • 摘自:www.twblogs.net/a/5cfe4bdfb…

至此,就总结了 WKWebView 检测内存的办法,核算最大内存约束的办法和默许的处理办法。

参考

  • developer.apple.com/forums/thre…
  • www.twblogs.net/a/5cfe4bdfb…
  • justinyan.me/post/3982
  • www.jianshu.com/p/22a077fd5…