持续创作,加速成长!这是我参与「掘金日新计划 · 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));
_WKProcessTerminationReason
和 ProcessTerminationReason
的转换关系如下:
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()
方法中,
- 先通过
memorySizeAccordingToKernel()
获取内核的内存大小; - 如果是 iOS 系统,再获取 jetsam 的限制:
jetsamLimit()
,在内存大小和 jetsamLimit() 中取较小的值; - 将结果向上取整为 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
逻辑为:
- 如果是模拟器,则内存设定为 1024M。
- 如果是真实设备,则通过
host_info
获取结构体为host_basic_info_data_t
的信息,读取max_mem
的数值,然后与std::numeric_limits<size_t>::max()
进行比较,取其中较小的值。其中std::numeric_limits<size_t>::max()
为当前设备可以表示的最大值。 - 计算失败时,返回 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()
中,
- 通过
memorystatus_control()
获取结构体为memorystatus_memlimit_properties_t
的信息,返回值不为 0,则返回 840M; - 如果获取的 memoryStatus 的限制属性 memlimit_active 小于 0 时,则返回当前设备可以表示的最大值;
- 如果运行正常,则返回系统返回的数值。
至此,就看到了 ramSize()
的整个计算过程。
总结一下内存最大限制的计算方法:
- 判断线程当前的状态:
- 激活状态
- 计算 ramSize();
- 计算内核的内存大小和 jetsam 的限制,取较小值,
- 向上取整为 128M 的倍数。
- 计算
baseThreshold = ramSize() > 16 * GB ? 15 * GB : 7 * GB;
- 最终结果为:baseThreshold + tabCount * GB;
- 计算 ramSize();
- 非激活状态:
- 在
CPU(X86_64) || CPU(ARM64)
下,baseThreshold = 3 * GB + tabCount * GB;
,否则baseThreshold = tabCount > 1 ? 3 * GB : 2 * GB;
; - 最终结果为:
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(_)
的代理方法:
- 则根据 crash 的原因判断是否要重新刷新;
- 重新刷新有最大次数限制(一次),超过则不会进行刷新。
后记:我们在iOS的Safari上测试了safari的白屏处理逻辑,当第一次发生白屏时Safari会默认重刷,第二次时safari会展示错误加载页,提示当前页面多次发生了错误。这个逻辑和上面webkit的默认处理逻辑时相似的。
至此,就总结了 WKWebView 检测内存的方法,计算最大内存限制的方法和默认的处理方法。