*** Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘An instance 0x of class A was deallocated while key value observers were still registered with it. Current observation info:
( <NSKeyValueObservance 0x: Observer: 0x, Key path: value, Options: <New: NO, Old: NO, Prior: NO> Context: 0x, Property: 0x>
泡上一杯上好的农夫山泉,我们开始了debug之旅。
crash信息很清楚,A was deallocated while key value observers were still registered with it
,下面的code可以100%复现这个问题
1 | class A: NSObject { |
code很简单,我们用Swift 4的新KVO语法去observe A上面的一个属性。通过a = nil
强制释放,我们得到了相同的crash
Google之后,发现这是一个已知的iOS 11以下的bug: https://bugs.swift.org/browse/SR-5816
This is happening because NSKeyValueObservation holds a weak reference to an object. That weak reference turns to nil too soon.
找到原因之后,修复变得简单,对于iOS 11以下的版本,在deinit
的时候,我们显示的调用removeObserver
一下
1 | deinit { |
为什么之前的版本没有这个问题?
之前的版本我们的工程师写出了下面的code
1 |
|
上面的code是一个经典的循环引用,导致A从来不会被释放,也就从来不会触发这个crash
项目中还有类似的下面的code,为什么不会crash?
1 | class A: NSObject { |
唯一的区别就是A中现在观察的对象是B上的属性,而不是A的,为什么这样不会导致crash?
对于2中的代码,假如我们把之前的fix加上
1 | deinit { |
我们发现会造成下面的crash,这又是为什么?
*** Terminating app due to uncaught exception ‘NSRangeException’, reason: ‘Cannot remove an observer <Foundation.NSKeyValueObservation 0x> for the key path “value” from because it is not registered as an observer.’
对于2和3的问题,希望后续有时间可以找到原因,同时也把这个问题抛到了这里:https://stackoverflow.com/questions/54730152/weird-swift-4-closure-based-kvo-bug-on-ios-10-devices
我们发现,使用WKWebView
做套壳浏览器,有个很严重的问题,浏览历史不同持久化。
WKWebView
有个属性叫作backForwardList (注意是get-only的)1
var backForwardList: WKBackForwardList { get }
A WKBackForwardList object maintains a list of visited pages used to go back and forward to the most recent page.
也就是说,我们在浏览器里看到的前进/后退按钮,其实就可以由它控制。backList表示所有可以后退到的item,forwardList表示所有可以前进到的item,其中的每个item是WKBackForwardListItem的实例,包含url
等信息
1 | var backList: [WKBackForwardListItem] { get } |
同时,WKWebView
暴露了下面的api允许自身在浏览历史中跳转。1
2
3
4
5
6// Navigates to the back item in the back-forward list.
func goBack() -> WKNavigation?
// Navigates to the forward item in the back-forward list.
func goForward() -> WKNavigation?
// Navigates to an item from the back-forward list and sets it as the current item.
func go(to: WKBackForwardListItem) -> WKNavigation?
浏览器有一个很常见的必备需求 – 保存浏览历史。比如用户访问了A, B, C三个网站,并且退出App的时候处于B网站。那么下次冷启动App的时候,用户自然而然会expect进入B的页面,并且可以后退/前进到A/C。
然而我们发现,WKWebView
本身并没有这样的方法,每次app重新启动初始化WKWebView
的时候,backForwardList
是全新的,这意味着在用户看来,之前的浏览历史全部消失了!
WKWebView
本身暴露这样一个功能(然而并没有)backForwardList
,然后assign给WKWebView
(很遗憾前面我们提到了backForwardList
是get only的,我们无法这样实现)backForwardList
本身是get only的,但是我们可以利用WKWebView
暴露的load
接口来模拟实现我们来简单实现一下方法3, 这是我们的sample app,有前进/后退两个按钮和一个WebView。
Persist:1
2
3
4
5
6
7
8
9
10
11
12guard let currentItem = self.webView.backForwardList.currentItem else {
return
}
guard let filePath = self.backforwardHistoryFilePath else {
return
}
let urls = (self.webView.backForwardList.backList + [currentItem] + self.webView.backForwardList.forwardList).compactMap { $0.url }
let currentIndexButLast = self.webView.backForwardList.forwardList.count
let backforwardHistory = BackforwardHistory(urls: urls, currentIndexButLast: Int32(currentIndexButLast))
NSKeyedArchiver.archiveRootObject(backforwardHistory, toFile: filePath)
Restore:1
2
3
4
5
6
7
8
9
10if let filePath = self.backforwardHistoryFilePath, let backforwardHistory = NSKeyedUnarchiver.unarchiveObject(withFile: filePath) as? BackforwardHistory {
backforwardHistory.urls.forEach { url in
self.webView.load(URLRequest(url: url))
}
for _ in 0..<backforwardHistory.currentIndexButLast {
self.webView.goBack()
}
}
思路很简单,
WKWebView
的backList, currentItem, forwardList
来保存url
,通过forwardList.count
来计算当前页面的index,然后通过NSKeyedArchiver
(我们先不考虑performance问题)来做持久化NSKeyedUnarchiver
反序列化文件,然后依次加载url,同时结合currentIndexButLast
和goBack
来实现跳转到指定页面通过上面的实现,我们发现当访问了A->B->C重启app之后,webview可以恢复历史并且显示C页面,但是却出现了无法后退到B的问题。原因是
WKWebView
的load
之后,页面加入到backForwardList
是需要时间的;官方没有介绍具体的时机,推测大概是在didFinish
左右url
并连续调用webview.load
,这导致前面的页面还没有加入到backForwardList
之中我们就跳转到了其他页面,于是最后的结果就是WKWebView
的backForwardList
中只有最后一个url
goBack()
的时候,backForwardList
中没有足够的item,无法后退一种可能的解决思路是等前一个页面加载完成之后,我们再加载第二个页面,但这样做一是用户体验会差太多,二是不够可靠因为无法确切的知道页面加入到backForwardList
的时机。
Stack Overflow上有同学提出了类似的问题
Why I can’t save WKWebView to [NSUserDefaults standardUserDefaults]?
Firefox for iOS的工程师给出了他们的解决思路:
There is no easy answer here. The WKWebView cannot be archived and it does also not participate in UI state preservation/restoration. You already discovered this.
For Firefox for iOS we took a different route to work around these limitations. This far from ideal but it does work.
When we restore a tab that has session info (previously visited pages) attached, we load a special html page from a local web server (running on localhost) that modifies the push state of the page and pushes previously visited URLs on the history stack.
Because these URLs need to be of same origin, we basically push urls like `http://localhost:1234/history/item?url=http://original.url.com
In our UI we translate this to display original.url.com. When the user goes back in history to load these, we intercept and instead of loading the page from localhost, we load the original page.
It is a big hack but that is all we have at this point.
See the source code at https://github.com/mozilla/firefox-ios
pushState
来达到修改backForwradList
的目的url
的时候,之前启动的本地server可以将其重定向到正确的url
GCDWebServer
和SwiftyJSON
文件来源:
https://github.com/mozilla-mobile/firefox-ios/blob/v10.x/Client/Assets/SessionRestore.html
code很少注释也写的很清楚,在我们通过http://localhost:{port}/errors/restore?history={"currentPage": -1, "history": ["http://1.com", "http://2.com"]}
加载这个页面的时候
{"currentPage": -1, "history": ["http://1.com", "http://2.com"]}
,然后将url转换成local格式{"currentPage": -1, "history": ["/errors/error.html?url=http://1.com", "/errors/error.html?url=http://2.com"]}
pushState
达到修改WKWebView
的backForwardList
的效果go
跳转到backForwardList
中某个位置1 | <!-- This Source Code Form is subject to the terms of the Mozilla Public |
文件来源:
https://github.com/mozilla-mobile/firefox-ios/blob/v10.x/Client/Application/WebServer.swift
这里的实现比较简单,利用GCDWebServer
这个库,起了一个本地server并暴露了路由方法。
1 | class WebServer { |
文件来源:
这个文件主要是用来注册两个路由
/errors/restore?history=...
是app刚启动恢复浏览记录的时候加载的url
/errors/error.html?url=...
是SessionRestore.html
最后加载的url
,这里我们会将实际的url
取出并加载1 | class SessionRestoreHandler { |
文件来源:
https://github.com/mozilla-mobile/firefox-ios/blob/master/Client/Frontend/Browser/Tab.swift
逻辑很清楚,将之前保存的url
和currentPage
信息拼好传到url
里然后加载
1 | let jsonDict: [String: AnyObject] = [ |
因为我们需要加载localhost,所以需要在Info.plist
里面添加如下key-value.
1 | <key>NSAppTransportSecurity</key> |
WKWebView
的backForwardList
是只读的,这一点限制了我们对浏览历史进行恢复操作pushState
接口,绕过了这个限制,从另一个角度修改了backForwardList
WKWebView
来渲染网页,有一个需求是在不同的账号下希望实现浏览状态隔离,如何实现呢?下图是我们用来测试的sample app, 两个tab分别有两个不同的WKWebView
实例,每次用户切换tab的时候,对应的tab会刷新对https://login.live.com
这个地址的访问。
WKWebView
实例是不会共享浏览状态的,因此我们可以在两个tab登录不同的账号,切换tab的时候对应的登录状态不会乱掉Keep me signed in
,奇怪的事情发生了,当切换到另一个tab的时候,发现另一个tab竟然也处于登录的状态Keep me signed in
的时候发生了什么?WKWebView
的体现就是,前者会在WKWebView
实例消失的时候随之消失,而后者会保存在文件系统中。当用户勾选Keep me signed in
的时候,网页会写入某些persistent cookie
,这样当app下次启动初始化WKWebView
的时候,网页就能读取到这些内容保持用户的登录状态。Keep me signed in
,但是人为删除Cookies
文件时,网页的登录状态会丢失,这样也进一步验证了我们的想法(也可以打开Cookies
文件查看内容)为什么默认状态下,WKWebView
不会共享浏览状态?
默认状态下,不同的WKWebView
拥有不同的processPool, 因此浏览状态相互之间是不会共享的。
为什么选择Keep me signed in
的时候浏览状态会共享?
首先要介绍WKWebsiteDataStore的概念
A WKWebsiteDataStore object represents various types of data used by a chosen website. Data types include cookies, disk and memory caches, and persistent data such as WebSQL, IndexedDB databases, and local storage.
iOS SDK提供了两种WKWebsiteDataStore
, WKWebsiteDataStore.default()会返回默认的, persistent dataStore,而WKWebsiteDataStore.nonPersistent()会创建一个non-persistent dataStore并返回(苹果的文档也介绍了,这种dataStore经常被用来实现无痕浏览)。
If a web view is associated with a nonpersistent data store, no data is written to the file system. This property implements private browsing in a web view.
默认状态下,WKWebView
的websiteDataStore是default版本的,也就是支持persistent cookie的dataStore
WKProcessPool
(默认行为),并不能保证浏览状态的隔离;由于默认状态下使用了相同的支持persistent cookie的WKWebsiteDataStore.default()
,网页需要persistent的cookie会共享。从上面的分析来看,解决方法看起来很直接,我们可以让不同的WKWebView
持有不同的WKWebsiteDataStore.nonPersistent()
实例1
2
3
4
5
6private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.nonPersistent()
let view = WKWebView(frame: .zero, configuration: config)
return view
}()
我们发现无论是否勾选Keep me signed in
,登录状态都不会共享,太好了!
当用户勾选了Keep me signed in
,杀掉app之后,我们发现登录状态”竟然”丢失了!这样虽然我们实现了登录状态隔离,但是Keep me signed in
这个选项变得无用,这可不是一个专业的工程师希望看到的。而我们也知道,这也是我们采用了上述方案之后expected结果,因为WKWebsiteDataStore.nonPersistent()
本身就是不支持persistent cookie的。
我们看起来进退维谷(这是一个视频链接,请不要在公众场合打开 ;))
WKWebsiteDataStore
WKWebsiteDataStore.default()
才能保存persistent cookieWKWebsiteDataStore.default()
是一个类似单例的存在,我们无法创建不同的persistent WKWebsiteDataStore
于是我们开始尝试:
然后在面向StackOverflow编程无果之后,你终于意识到自己已经是有经验的工程师了,应该可以独立解决问题了,在苦思冥想之后,终于想到了下面的方法
去看Webkit的源码,想办法使用私有的api
在浏览_WKWebsiteDataStoreConfiguration.h
这个文件时,我们发现如下的定义
1 |
|
于是理论上我们可以创建WKWebsiteDataStoreConfiguration
之后,配置不同的cookieStorageFile
路径,然后利用下面的private API,来创建不同的支持的persistent cookie的dataStore
1 | @interface WKWebsiteDataStore (WKPrivate) |
但是我们担心无法通过苹果的审核,同时这样做需要花更多的时间去看源码确保是可行的,作为一名有经验的工程师,我们认为这不是一条正路
终于,我们意识到,既然支持persistent cookie的本质就是把cookie保存到文件系统里,那我们能不能自己来实现呢?
对于我们的sample app,我们的方案如下:
WebView
实例分别使用WKWebsiteDataStore.default()
和WKWebsiteDataStore.nonPersistent()
WKWebsiteDataStore.default()
的WebView
实例,我们依靠其自身的cookie persistence机制WKWebsiteDataStore.nonPersistent()
的WebView
实例webView
的所有cookie并保存到文件里webView
的时候,读取文件中的所有cookie并加载到webView
中简单明了的方案,当然我们的代码也应该体现出我们的水平。
WKWebsiteDataStore
,暴露了persistCookies
和restoreCookies
两个方法,暴露了cookieStorageFile
这个属性sample app中使用了最简单的NSKeyedArchiver
来实现数据持久化(要注意在操作大文件时archive/unarchive是耗时的操作,如果可能尽量放到其他线程去做;但同时cookie的读写又必须在主线程上进行)
1 | struct WKWebsiteDataStoreAssociatedKeys { |
在使用的时候,只要给webSiteDataStore
设置cookieStorageFile
并且在合适的时机调用restoreCookies
和persistCookies
即可。
于是,我们设计的这套方案,”完美”(作为一名有经验的工程师,我们知道后续还会有各种各样奇奇怪怪的问题/需求出现)的解决了
WebView
之间浏览状态隔离WebView
的情况,只需设置不同的WKWebsiteDataStore.nonPersistent()
和cookieStorageFile
即可终于,我们有时间感慨,今晚的月色真美啊!
]]>gcc
.我做的事情是希望在Web环境中用到C++的一个库,所以套用上面的术语,就是我会用利用emsdk
提供的环境,使用emcc
将C++
编译成WebAssembly
格式, 然后在Web
环境中使用.
image source: https://kripken.github.io/emscripten-site/docs/introducing_emscripten/about_emscripten.html
实践中我们比较关心的关于Thread的兼容性:
Tables | Available version | Conditions |
---|---|---|
Google chrome | Chrome 70 | Turn on experimental “WebAssembly threads support” flag |
Mozilla firefox | Firefox nightly channel | Turn on “javascript.options.shared_memory” flag |
Emscripten has support for multithreading using the new SharedArrayBuffer capability in browsers. Note that SharedArrayBuffer was disabled by default in all major browsers on 5 January, 2018 in response to Spectre. Chrome re-enabled it in v67 on platforms where its site-isolation feature is enabled to protect against Spectre-style vulnerabilities.
1 | # Get the emsdk repo |
Sample JavaScript-C++ interoperability code
hello.cc
#include <pthread.h>#include <iostream>extern "C"{ extern int js_func(); void* test(void*) { std::cout << "Background thread" << std::endl; std::cout << js_func() << std::endl; return NULL; } void cpp_func() { pthread_t t; pthread_create(&t, NULL, &test, NULL); std::cout << "Main thread" << std::endl; }}
hello.js
Module.onRuntimeInitialized = () => { const cpp_func = Module.cwrap('cpp_func', null); cpp_func();};
hello.html
<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title></head><body> <script src="wasm.js"></script> <script src="hello.js"></script></body></html>
export.js
mergeInto(LibraryManager.library, { js_func: function () { return 10; },});
Build
emcc hello.cc -o wasm.js -s EXPORTED_FUNCTIONS='["_cpp_func"]' -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' --js-library export.js -std=c++14 -s USE_PTHREADS=1 -s PTHREAD_POOL_SIZE=2 -g4
Build完之后会生成wasm.js
和wasm.wasm
,其中wasm.js
已经在hello.html
中被引用,wasm.wasm
会在wasm.js
中被fetch
, compile
and instantiate
直接打开hello.html
看到输出
目前踩到的一些比较棘手的坑(嗯小坑不断)
embind
和webidl
的环境下,JavaScript能够实现C++中定义的interface,但却无法在C++的multi-thread环境中被调用Module
中JavaScript中实现的Objectpthread-main.js
(worker)中, Module
并不包含JavaScript实现的Object,具体的重现过程可以参考我在上面链接中的提出的issueimage source: https://stackoverflow.com/questions/35504571/is-ios-app-store-over-the-air-download-limit-based-on-download-size-or-instal
Inspect ipa files to examine the compressed size of each item in the .ipa file
unzip -lv {app}.ipa
image source: https://stackoverflow.com/questions/52422675/how-to-extract-contents-from-a-ipa-file
Leverage LinkMap to analyze the composition of main executable file
image source: https://github.com/kobe1941/shell
We have integrated our package size report into our CI and it will auto generates report and mail alert
There’re some potential optimization methods which need more efforts, we’re actively evaluating their feasibilities
这部分主要参照Hadoop 2.7.1 for Windows 10 binary build with Visual Studio 2015 (unofficial)
D盘下新建目录D:\Hadoop 用来存放所有的Hadoop配置相关文件
下载地址: Java SE Development Kit 8u73 Windows x64
安装地址: D:\Hadoop\jdk1.8.0_73
将环境变量 JAVA_HOME 设置为jdk的位置 D:\Hadoop\jdk1.8.0_73
下载地址: hadoop-2.7.2-src.tar.gz
解压地址: D:\Hadoop\hadoop-2.7.2-src
打开 D:\Hadoop\hadoop-2.7.2-src\BUILDING.txt ,里面列出了其他的Requirements:
下载地址: apache-maven-3.3.9-bin.tar.gz
解压地址: D:\Hadoop\apache-maven-3.3.9
将 D:\Hadoop\apache-maven-3.3.9\bin 添加到PATH环境变量中
下载地址: protocol-2.5.0-win32.zip
解压地址: D:\Hadoop\protoc-2.5.0-win32
将 D:\Hadoop\protoc-2.5.0-win32 添加到PATH环境变量中
下载地址: cmake-3.5.0-rc2-win32-x86.msi
安装时记得勾选添加到PATH环境变量
下载地址: zlib128-dll.zip
解压地址: D:\Hadoop\zlib128-dll
在环境变量中添加ZLIB_HOME,值为D:\Hadoop\zlib128-dll\include
根据BUILDING.txt,这个tool可以在安装git的时候顺带安装
下载地址: git-2.7.1
安装的时候记得勾选 “Use Git and optional Unix tools from the Windows Command Prompt”
将C:\Windows\Microsoft.NET\Framework64\v4.0.30319添加到PATH环境变量中
用Visual Studio 2015打开下面两个solution,右键solution,选择Retarget Projects
打开 D:\Hadoop\hadoop-2.7.2-src\hadoop-hdfs-project\hadoop-hdfs\pom.xml ,将下面这一行改为Visual Studio 2015的形式
修改前1
<condition property="generator" value="Visual Studio 10" else="Visual Studio 10 Win64">
修改后1
<condition property="generator" value="Visual Studio 10" else="Visual Studio 14 2015 Win64">
启动 Developer Command Prompt for VS2015
在 D:\Hadoop\hadoop-2.7.2-src 下执行下面的命令设置 Platform environment variable
1 | set Platform=x64 |
然后执行如下命令开始build
1 | mvn package -Pdist,native-win -DskipTests -Dtar |
build过程中如果出现 OutOfMemoryError,通过下面的命令 assign more memory,然后重新build
1 | set MAVEN_OPTS=-Xmx512m -XX:MaxPermSize=128m |
如果出现jni.h找不到的错误,可以将下面三个文件复制到 C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\include 下
1 | D:\Hadoop\jdk1.8.0_73\include\jni.h |
Build成功后,将 D:\Hadoop\hadoop-2.7.2-src\hadoop-dist\target\ 下的hadoop-2.7.2文件夹复制到 D:\Hadoop\hadoop-2.7.2
接下来就可以参照Build and Install Hadoop 2.x or newer on Windows,配置Single Node Cluster,注意Command Prompt必须具有Admin权限,否则在执行yarn指令时会报错 “A required priviledge is not held by the client”
这部分内容可以参照HOW-TO: COMPILE AND DEBUG HADOOP APPLICATIONS WITH INTELLIJ IDEA IN WIDNOWS OS(64BIT)
注意的地方是WordCount例程中不要忘记下面这一行,否则Class not found
1 | job.setJarByClass(WordCount.class); |
这部分内容参照Intellij IDEA 搭建Hadoop开发环境
要注意的地方是修改args[0]和args[1],将原先WordCount中的下面两行
1 | FileInputFormat.addInputPath(job, new Path(args[0])); |
改为
FileInputFormat.addInputPath(job, new Path(args[1]));FileOutputFormat.setOutputPath(job, new Path(args[2]));
原因可以参考这里org.apache.hadoop.mapred.FileAlreadyExistsException