由TextEngine原生渲染迁移至WebView渲染
一、背景现状
应项目需要在原有渲染技术基础上(支持普通文本渲染、图片渲染、链接等,渲染元素过于单一)扩展支持音频、视频等丰富功能。Android因为基于自身开发的TextEngine(基于Recyclerview的type),勉强还能继续扩展。但是由于TextEngine这套渲染引擎对开发者掌握要求过高,维护成本过高,且在支持连字符、两边对齐效果方面,实现技术成本过高。
此外iOS由于基于自有的UITextView进行扩展的渲染的,要支持视频、音频、更换主题皮肤等功能更是很难,且iOS现有两边对齐渲染效果不佳。所以阅读APP急需一套可扩展、统一、高效、最好是跨平台的渲染解决方案。
二、面临问题
1、Android的自有TextEngine渲染引擎框架维护成本过高,扩展新类型渲染元素技术成本过高。
2、Android现有TextEngine未支持按音节断字渲染效果。
3、iOS现有文本渲染效果不佳,且与Android实现两边对齐渲染算法不一致。
4、iOS基于自身UI组件UITextView组件进行文本渲染,无法满足支持音视频组件渲染需求。
三、采用本地HTML模板+WebView渲染优点
利用HTML自身音视频标签更容易扩展音视频元素渲染。
利用css属性仅需要一个justify-all,轻松实现Android, iOS实现同一套两边对齐渲染方案。
利用webView渲染,实现了Android、iOS复用同一套渲染逻辑的代码,渲染效果得到极度统一。
四、如何实现重构
整体的详情页的文本渲染,完全采用webview来渲染。为了提高渲染加载速度,可以提前在App本地目录放置好Html模板、css、js资源,然后直接从本地加载html, 等待页面中网络数据请求解析完毕,只需要动态将请求的数据内容替换插入html中即可,这样会比直接加载一个URL会快很多。由于需要特定标记单词或点击查词功能,所以采用span标签单独包裹一个单词,但是会有一些点击查词、日夜间皮肤更换、字体大小更换、标注困难的词组以及已经学过单词的需求,那就避免不了是web和native的双向通信,web到native就采用一种基于native自定义协议的URL拦截调用native一些组件,native到web就采用webview api通信规则。
五、采用WebView渲染遇到新的问题
1、WebView首次渲染过慢问题,需要优化。
2、Android中基于Chrome内核,不支持自带属性按音节进行单词断字;但是iOS支持。Android需要优化,需要自己单独去实现文本音节断字器。
3、虽然web中css支持justify两边对齐,但是排版效果还是不让人满意,不是很美观。所以需要去实现一套渲染排版两边对齐的算法。(目前基于Knuth-Plass算法,简称KP文本排版算法
4、 js和Native交互方式采用URL拦截方式,维护成本很高,每增加一个js和Native交互就需要新定义URL,native实现拦截,方法过于原始,维护的URL成本过高,也就容易出错。需要实现一套类似jsBridge,js和native通信交互框架-PieBridge(鹊桥)
5、js调用native的时候采用调用native通过
addJavaScriptInterface
时候, 注入一个对象,但是需要注意的是js调用这个注入的对象必须等到页面加载完毕后才能调用注入的对象否则就会报错:Error: Java bridge method can't be invoked on a non-injected object
六、涉及到核心技术点
1、WebView或H5首次渲染优化方案
2、文本断字器实现原理
3、基于Knuth-Plass算法实现两边对齐的原理
4、PieBridge的实现原理
5、X5WebView相关知识点、webview生命周期、浏览器实现原理
6、Android 自定义View实现绘制文本
七、WebView或H5首次渲染优化方案
详细请见Android H5首屏秒开优化方案.md
八、文本断字器(Hypentation)实现原理
1、基本原理
首先对于英文单词的断字,是基于音节来划分的,换句话说一个单词不再独立单元,一个单词可以按照音节断成若干部分,中间用连字符连接。
首先,输入一个单词比如hypenation
, 首先在首尾加上.
的标记组成最终的匹配串。
.hypenation.
, 由匹配串分别产生长度为k的字符串,比如下面这些:
//长度为1的子串
. h y p e n a t i o n .
//长度为2的子串
.h hy yp ph he en na at ti io on n.
//长度为3的子串
.hy hyp yph phe hen ena nat ati tio ion on.
...
然后,分别拿到这些单词的子串,去对应英文中的模式字典(Pattern Dictionary)中匹配,针对每一门语言的模式字典都是由相关的语言研究专家产生。这些模式字典(Pattern Dictionary)是由模式串加数字组成,如果子串匹配对应模式串,就会对应的数字标记添加到子串右下角作为下标值,如果没有匹配到的,那么默认使用0替代。类似下面经过模式匹配后这样:
然后最后取每个字母下标值最大的作为最终单词的每个字母的下标: 比如h和y之间
最大的值为0,y和p之间
最大值为3、p和h之间
最大值为0、h和e之间
最大的值为0、e和n之间
最大值为2(2 来自于 0h0e2n0,0 来自于 0h0e0n0a4, 0 来自于 0h0e0n5a0t0, 1 来自于 1n0a0, and 0 来自于 0n2a0t0)、n和a之间
最大值为5(0来自于0h0e2n0,0来自于0h0e0n0a4,5来自于0h0e0n5a0t0)、a和t之间
最大值为4、t和i
之间最大值为2、i和o
之间最大值为0、o和n之间
最大值为2、n和.
之间最大值为0.所以最终这个词带下标的序列是:
最终,由这些下标值来划分音节,对于英语单词而言,如果下标值为奇数,且划分断点处产生的前缀长度不低于2,后缀长度不低于3,才满足最终英文单词音节断字划分。所以hypenation,最终的划分音节单词为:
2、核心代码实现
1、首先,定义好各种语言的Hypenation模式字典,也就是所谓的Pattern.
//美式英语的Pattern,2,3分别代表连字符前缀最小长度和后缀最小长度 EN_US(2, 3, new HashMap<Integer, String>() { { put(3, "x1qei2e1je1f1to2tlou2w3c1tue1q4tvtw41tyo1q4tz4tcd2yd1wd1v1du1ta4eu1pas4y1droo2d1psw24sv1dod1m1fad1j1su4fdo2n4fh1fi4fm4fn1fopd42ft3fu1fy1ga2sss1ru5jd5cd1bg3bgd44uk2ok1cyo5jgl2g1m4pf4pg1gog3p1gr1soc1qgs2oi2g3w1gysk21coc5nh1bck1h1fh1h4hk1zo1ci4zms2hh1w2ch5zl2idc3c2us2igi3hi3j4ik1cab1vsa22btr1w4bp2io2ipu3u4irbk4b1j1va2ze2bf4oar1p4nz4zbi1u2iv4iy5ja1jeza1y1wk1bk3fkh4k1ikk4k1lk1mk5tk1w2ldr1mn1t2lfr1lr3j4ljl1l2lm2lp4ltn1rrh4v4yn1q1ly1maw1brg2r1fwi24ao2mhw4kr1cw5p4mkm1m1mo4wtwy4x1ar1ba2nn5mx1ex1h4mtx3i1muqu2p3wx3o4mwa1jx3p1naai2x1ua2fxx4y1ba2dn1jy1cn3fpr2y1dy1i"); }, ... }) //法语的Pattern FR(2, 3, new HashMap<Integer, String>() { { put(2, "1ç1j1q"); put(3, "1gè’â41zu1zo1zi1zè1zé1ze1za’y4_y41wu1wo1wi1we1wa1vy1vû1vu1vô1vo1vî1vi1vê1vè1vé1ve1vâ1va’û4_û4’u4_u41ba1bâ1ty1be1bé1bè1bê1tû1tu1tô1bi1bî1to1tî1ti1tê1tè1té1te1tà1tâ1ta1bo1bô1sy1sû1su1sœ1bu1bû1by2’21ca1câ1sô1ce1cé1cè1cê1so1sî1si1sê1sè1sé1se1sâ1sa1ry1rû1ru1rô1ro1rî1ri1rê1rè1ré1re1râ1ra’a41py1pû1pu1pô1po1pî1pi1pê1pè1pé1pe1pâ1pa_ô41ci1cî’ô4’o4_o41nyn1x1nû1nu1nœ1nô1no1nî1ni1nê1nè1né1ne1nâ1co1cô1na1my1mû1mu1mœ1mô1mo1mî1mi1cœ1mê1mè1mé1me1mâ1ma1ly1lû1lu1lô1lo1lî1li1lê1lè1cu1cû1cy1lé1d’1da1dâ1le1là1de1dé1dè1dê1lâ1la1ky1kû1ku1kô1ko1kî1ki1kê1kè1ké1ke1kâ1ka2jk_a4’î4_î4’i4_i41hy1hû1hu1hô1ho1hî1hi1hê1hè1hé1he1hâ1ha1gy1gû1gu1gô1go1gî1gi1gê_â41gé1ge1gâ1ga1fy1di1dî1fû1fu1fô1fo’e41fî1fi1fê1fè1do1dô1fé1fe1fâ1fa’è41du1dû1dy_è4’é4_é4’ê4_ê4_e41zy"); }, ... }),
2、然后,把这些语言的Pattern生成一个字典树Trie
private static TrieNode createTrie(Map<Integer, String> patternObject) { TrieNode tree = new TrieNode(); for (Map.Entry<Integer, String> entry : patternObject.entrySet()) { int key = entry.getKey(); String value = entry.getValue(); for (int i = 0; i + key <= value.length(); i = i + key) { createTrie(tree, value, i, i + key); } } return tree; } private static void createTrie(TrieNode root, String value, int start, int end) { TrieNode t = root; for (int c = start; c < end; c++) { char chr = value.charAt(c); if (Character.isDigit(chr)) { continue; } int codePoint = value.codePointAt(c); if (t.codePoint.get(codePoint) == null) { t.codePoint.put(codePoint, new TrieNode()); } t = t.codePoint.get(codePoint); } IntArrayList list = new IntArrayList(); int digitStart = -1; for (int p = start; p < end; p++) { if (Character.isDigit(value.charAt(p))) { if (digitStart < 0) { digitStart = p; } if (p == end - 1) { // last number in the pattern String number = value.substring(digitStart, end); list.add(Integer.valueOf(number)); } } else if (digitStart >= 0) { String number = value.substring(digitStart, p); list.add(Integer.valueOf(number)); digitStart = -1; } else { list.add(0); } } t.points = list.toArray(); }
3、最后,通过传入的单词,来产生各种长度的子串,然后用这些子串去生成的字典树中去匹配查找,找到对应的数字下标,如果数字小标是奇数且,该下标的位置产生的前缀长度大于等于2且产生的后缀长度大于等于3,说明此处就是连字符的位置。那么最后返回单词按连字符拆开后字符串子串数组。
public List<String> hyphenate(String word) { word = "_" + word + "_"; String lowercase = word.toLowerCase(); int wordLength = lowercase.length(); int[] points = new int[wordLength]; int[] characterPoints = new int[wordLength]; for (int i = 0; i < wordLength; i++) { points[i] = 0; characterPoints[i] = lowercase.codePointAt(i); } TrieNode node, trie = this.trie; int[] nodePoints; for (int i = 0; i < wordLength; i++) { node = trie; for (int j = i; j < wordLength; j++) { node = node.codePoint.get(characterPoints[j]); if (node != null) { nodePoints = node.points; if (nodePoints != null) { for (int k = 0, nodePointsLength = nodePoints.length; k < nodePointsLength; k++) { points[i + k] = Math.max(points[i + k], nodePoints[k]); } } } else { break; } } } List<String> result = new ArrayList<String>(); int start = 1; for (int i = 1; i < wordLength - 1; i++) { if (i > this.leftMin && i < (wordLength - this.rightMin) && points[i] % 2 > 0) { result.add(word.substring(start, i)); start = i; } } if (start < word.length() - 1) { result.add(word.substring(start, word.length() - 1)); } return result; }
九、基于Knuth-Plass算法实现两边对齐的原理
1、算法描述
在Knuth-Plasss算法中,一篇文章可以看做成若干个序列:x(1)、x(2)、x(3)、…、x(n)组成. 其中每个x(i),有如下数据结构:
Box: 需要被绘制的对象单元,它可以是一个字符、一个表情等。一个Box在Tex中长方形二维对象,有三个相应的尺寸: 高度、宽度和深度
Glue: 对应Box间的空格。在KP算法里,Glue有三个属性(Wi, Yi, Zi). Wi对应正常空格的宽度(space: 正常间距),Yi对应可拉伸的宽度(stretch: 伸长能力),Zi对应可压缩的宽度(shrink: 收缩能力)。当正常空格正常空格显得太大的时候,可调节Zi的值,当正常空格显得太小的时候,可调节Yi的值。
第一个粘连单元是 9 个单位空格, 伸长 3 个, 收缩 1 个; 下一个也是 9 个单位空格, 但是伸长 6 个, 收缩 2 个; 最后一个是 12 个单位空格, 但是不能伸缩, 所以不管怎样它也要保持 12 个单位空格。
不考虑粘连的伸缩时, 本例中的盒子和粘连的总宽度是 5 + 9 + 6 + 9 + 3 + 12 + 8 = 52 个
单位。它称为水平列的自然宽度; 是把盒子粘在一起的最好结果。但是, 假定要求 TEX 把盒子
变成宽度为 58 个单位; 那么粘连必须伸长 6 个单位。好, 目前的伸长能力是 3 + 6 + 0 = 9 个单
位, 所以为了得到所需要的 6 个单位, TEX 用 6/9 乘以每个可伸长的单位。第一个粘连团变成 9 + (6/9) × 3 = 11 个单位的宽度, 第二个变成 9 + (6/9) × 6 = 13, 最后一个保持 12 个单位不变,我们将得到了如下这样的符合要求的盒子:
Penalty: 代表可能的末尾行。 算法会计算一个值,这个值能描述在当前点断行对排版审美上的影响,以此来判断是否要在当前点换行。每一个Penalty有一个参数pi来帮助我们是否要在这里开始断行。pi是一个任意的值,所以它的取值是 (-∞, +∞), 其中∞代表的是一个很大的数字,在KP算法里,当pi大于等于1000的时候就被认为是+∞,当pi小于等于1000的时候就被认为是-∞。当pi=+∞的时候,换行被严格禁止,当pi=-∞,需要强制换行。一个Penalty也有它自己的宽度,当断行发生在一个penalty的时候,我们需要人为的追加一个宽度wi到当前行。比如,一个断行点发生在可以加连字符的地方,我就追加一个wi到末行,wi等于连字符宽度。另外Penalty还有一个标记fi, 取值 0 或 1。fi的作用下文会提及
2、抽象概念
如果一个段落需要首行缩进,那么我们在进行抽象表达的时候可以令第一个元素是一个空的box并且宽度是首行缩进的宽度
一个单词可以变成box和penalty的序列,每个box含有的是单词中的每个字符,其中box宽度是受当前所用字体影响的,penalty的添加受音节的影响,它用来标注当前位置可以添加连字符。并且penalty是flagged的,即fi为1
单词间由glue连接,glue的宽度通常跟字体绑定。在TEX排版系统中,不同的上下文,glue所代表的语义有所不同。
显式的连字符或者短破折号(dash)后尾随一个flagged penalty。这个penalty的宽度为0。在有些排版风格中,针对长破折号(em dash),我们允许在长破折号之前断行,因此,我们在破折号之前加一个unflagged的penalty,并且它的宽度也是0。
在段落的末尾,总是添加一个glue,来表示在最后一行的右边会有一个空格,并且有一个pm=-∞的penalty来强制换行。
3、排版美观程度的标准
1、K(j) (1 <= j <= +∞): 表示第j行期望的宽度
2、L(j) (1 <= j <= +∞): 表示第j行真实宽度,它由第j行所有的box,glue宽(也就是glue中的space)累加得到的。注意: 如果第j行末尾符合音节断字的连字符,则需要考虑加上连字符的宽度。
3、Y(j) (1 <= j <= +∞): 表示第j行的可拉伸的宽度和,也就是glue中第j行所有的stretch伸长宽度的和。
4、Z(j) (1 <= j <= +∞): 表示第j行的可收缩的宽度和,也就是glue中第j行所有的shrink收缩宽度的和。
由此我们可以得到一个结论和得到一个R(j)系数:
if L(j) == K(j) 那么就是达到理想状态,期望的宽度和真实宽度一致,既不需要拉伸也不需要收缩,那么R(j)=0
if L(j) < K(j) 那么就是真实宽度L(j)小于期望值,需要调整Y(j)来拉伸,此时R(j) = (K(j) - L(j)) / Y(j);
if L(j) > K(j) 那么就是真实宽度L(j)大于期望值,需要调整Z(j)来收缩,此时R(j) = (K(j) - L(j)) / Z(j);
因此我们可以拿到R(j)系数后,我们计算出第j行所有glue宽度大小: (得到第j行R(j)系数后,那么第i个glue的宽度可表示为:
若R(j) >=0, 那么第j行的第i个glue的宽度 = glue的正常宽度为w(i) + glue的伸长能力宽度为y(i) * 第j行的R(j)系数
若R(j) < 0, 那么第j行的第i个glue的宽度 = glue的正常宽度为w(i) + glue的收缩能力宽度为z(i) * 第j行的R(j)系数
那么,我们就能得到第j行所有glue的宽度
如图表示:
当R(j)这个系数大小不一样时,会影响排版效果:
可以明显看到当R(j)小于0的时候, 因为会调整收缩能力Z(j),排布更紧凑;R(j)大于0的时候,因为会调整伸长能力Y(j),排布会更稀疏。
所以,我们要实现精美的排版效果, 可以得出KP算法的主要目标
尝试找出一种排版方式,使得所有的行都有接近相同大小间隔,这样整体就会很规整,尽量规避单词间距过大或过小。
从算法角度来说,就是尽量保持 |R(j)| <= 1
4、衡量算法的质量指标
在1960年代的时候,Duncan就对排版算法做了一些具有突破性的研究。不过在他的研究中,所有的指标都是依赖 wi - zi 或者 wi + yi 的,而不仅仅是 wi 自身,在某些情况下,单一的影响因子往往不能适应极端情况,在某些苛刻条件下,算法表现会比较差。在KP算法里,衡量优劣由多个因子影响,而不是简单的认为 rj 超过一定阈值就认定排版效果不理想。
我们引入一个定量的指标badness ratings*来表述第j行的排版情况,我们期望当|rj|足够小的时候,这个指标的值能够趋向于0,而当|rj|*大于1的时候,这个值又能变化的很快,很容易我们可以想到以下这个公式:
我们尝试计算下上文中的段落badness ratings
我们可以看到,当 rj < -1 的时候,我们认为是糟糕透顶的,因为在这个情况下,我们不能让空格压缩到小于wi - zi,这样会让你认为你在渲染一个超长的单词。但是rj > 1的情况我们是可以接受的,因为我无论怎么稀疏,到底是不会影响阅读效果的。
到这里我们可以再做一点改进,之前的算法中,我们的排版可以说是短视的,这有点像是贪心算法。我们永远都是仅让当前行足够完美,但是有的时候,我们可以重新回溯之前选择的点,以求得整体上的最优,你可以类比动态规划算法。
类比下图,我们在原先βj的基础之上引入 *βj + πj,*πj 是penalty的p属性,如果某一页不以penalty结尾,那么 πj 为 0,否则为penalty的p属性值
十、PieBridge(鹊桥)的实现原理
1、基本原理
PieBridge的本质是Native调用Js是通过loadUrl的方式执行调用Js中的方法;然后Js调用Native的本质是通过拦截两个URL(队列中有消息以及取队列中消息行为),来自Js层的调用Message消息信息,通过URL带过来。拦截URL取出消息执行相应的Native层的方法。
1、初始化
首先,在Native和Js层会初始化一个方法名(handlerName)和方法对象(handler)的哈希映射表。Native调用Js会传入唯一映射对应的方法名去Js层中找到相应方法对象,并执行这个方法。同理,Js调用Native也是通过唯一映射的方法名去Native层找到相应的Native的Handler并执行。
2、Native 调用 Js的原理(带Callback)
步骤1: Native调用callHandler, 传入所需参数
Native层通过调用
callHandler
方法(传入hanlderName
参数也即是Js层注册方法名,传入data
参数也即是Js层方法所需参数,传入callback
参数,用于回调Js层回调所需callback函数)。mPieBridge.callHandler("functionInJs", json, object : IHandlerCallback { override fun onCallback(data: String) { val jsonObject = JSONObject(data) Toast.makeText(this@MainActivity, "来自Js的数据: ${jsonObject.getStringValue("id")}", Toast.LENGTH_SHORT) .show() } }) //执行JS中注册的方法 override fun callHandler(id: String?, data: String, callback: IHandlerCallback?) { mMessageManager.postMessage(data, id, callback) }
步骤2: 调用MessageManager中的postMessage方法, 传入所需参数;构建Message实体,存储Callback
MessageManger
中的postMessage
首先会通过buildMessage
方法,根据传入参数构建一个Message
实体。如果callback
不等于null, 生成唯一映射的callbackId保存在Message
实体中, 并根据callbackId以及callback实例,保存在一个callback哈希映射表中。最后通过enqueueMessage
方法将消息插入MessageQueue
或立即分发dispatchMessage
。//构建消息实体Message private fun buildMessage( data: String, handlerId: String?, callback: IHandlerCallback? ): Message { val message = Message(Uri.encode(data, "UTF-8"))//注意: data传递JSON需要encode if (handlerId != null && handlerId.isNotBlank()) { message.handlerName = handlerId } if (callback != null) { //生成唯一callbackID val callbackID = PieBridgeHelper.processCallbackID(++mUniqueCallbackID) //把callbackID和callback保存在mCallbackMap中 mCallbackMap[callbackID] = callback //并把callbackID保存在Message实体中 message.callbackId = callbackID } return message } override fun postMessage(data: String, handlerId: String?, callback: IHandlerCallback?) { //通过enqueueMessage将消息Message插入队列或分发消息 enqueueMessage(buildMessage(data, handlerId, callback)) }
步骤3: 调用MessageManager中的enqueueMessage方法, 插入MessageQueue或立即dispatchMessage.
如果WebView此时已经加载完毕了,就调用
dispatchMessage
方法立即分发Message, 如果WebView还未加载完毕,那么就把Message
插入到MessageQueue
中,等待WebView加载完毕后,通过flushMessageQueue
方法,把队列中Message按照顺序通过dispatchMessage
方法全部分发出去。private fun enqueueMessage(message: Message) { val pageIsFinished = mListener.mPageIsFinishedAction?.invoke() ?: false //页面还未加载完毕,暂时先把消息加入startupMessage,带页面加载完毕批量分发消息 if (!pageIsFinished) {//未加载完毕,Message入消息队列 mMessageQueue.offer(message) } else {//如果是页面是已经加载完毕,可以立即分发消息 dispatchMessage(message) } }
步骤4: 调用MessageManager中的dispatchMessage方法, 把Message序列化成json,并拼接成jsCommand, 映射调用js中的_handleMessageFromNative。最后通过loadUrl执行这个jsCommand
private fun dispatchMessage(message: Message) { if (Thread.currentThread().isMainThread()) { val jsCommand = PieBridgeHelper.processJsCommand(message) Log.d("MessageManager", jsCommand) //jsCommand输出为: javascript:PieBridge._handleMessageFromNative('{data:xxx, handler_name:xxx, callback_id:xxx, response_id:null, response_data:null}'); mListener.mCallJsFuncAction?.invoke(jsCommand)//webview.loadUrl(jsCommand) } }
步骤5: 执行PieBridge.js层的中_handleMessageFromNative方法
首先,需要判断当前Js中的PieBridge对象是否初始化完毕,未初始化完毕就将
messageJSON
插入到Js中的ReceiveMessageQueue
中,等待PieBridge初始化完毕,通过init
方法中,将ReceiveMessageQueue
中所有MessageJSON
通过_dispatchMessageFromNative
方法分发出去;如果ReceiveMessageQueue
是空,说明此时PieBridge已经初始化完毕,可以直接调用_dispatchMessageFromNative
分发当前消息messageJSON
.// 提供给native调用,receiveMessageQueue 在会在页面加载完后赋值为null,所以 function _handleMessageFromNative(messageJSON) { if (receiveMessageQueue) {//receiveMessageQueue不为null,说明PieBridge对象还没初始化完毕 receiveMessageQueue.push(messageJSON); } _dispatchMessageFromNative(messageJSON); }
步骤6: 执行PieBridge.js层的中_dispatchMessageFromNative方法
在该方法中首先拿到
messageJSON
, 并对它做解析将它转化成js层的Message
,先判断responseId
是否为null,由于第一次是Native调用Js, 而不是js调用native。所以为null. 如果responseId为null, 然后再判断callbackId
是否为null, 如果callbackId
不为null,那么就创建一个以responseData
为回调参数的responseCallback
, 最后取出Message
中的handlerName
去js层中的方法哈希映射表中,找到对应js层方法,并把Message
中的data
以及responseCallback
作为方法参数,执行对应js方法。// 提供给native使用 function _dispatchMessageFromNative(messageJSON) { setTimeout(function() { const message = JSON.parse(messageJSON); message.data = decodeURIComponent(message.data) let responseCallback; if (message.responseId) { responseCallback = responseCallbacks[message.responseId]; if (!responseCallback) { return; } responseCallback(message.responseData); delete responseCallbacks[message.responseId]; } else { // 直接发送 if (message.callbackId) { const callbackResponseId = message.callbackId; //注意:如果js层方法执行完毕后,有回调数据到Native层会触发responseCallback responseCallback = function(responseData) { //回调到Native层实际上就是把Native层传入的callbackId和回调的数据组成一个Message,又将消息发送到Native层 _doSend({ responseId: callbackResponseId, responseData }); }; } let handler = PieBridge._messageHandler; //找到js中的方法映射表中的对应handlerName的方法 if (message.handlerName) { handler = messageHandlers[message.handlerName]; } // 查找指定handler try { //传入data以及刚创建的responseCallback为参数执行对应的目标方法。 handler(message.data, responseCallback); } catch (exception) { if (typeof console !== 'undefined') { console.log( 'PieBridge: WARNING: javascript handler threw.', message, exception, ); } } } }); }
步骤7: 执行main.js层的中注册的目标方法(到这里native就成功地执行了js中的方法)
//main.js window.PieBridge.registerHandler('functionInJs', function(data, responseCallback) { document.querySelector('body').innerHTML = `data from Java: = ${data}`; var jsonObject = {"dataType": "Banner","id": 1299,"title": "训练营"} if (responseCallback) {//通过responseCallback把js带给native的数据回调出去,可以是函数直接返回同步数据,也可以是异步数据。 responseCallback(JSON.stringify(jsonObject)); } });
步骤8: 执行传入的responseCallback(处理js回调到native的callback)
执行
responseCallback
,实际上将native传入的callbackId
作为responseId
以及responseData
组装一个Message
, 通过_doSend
方法将消息传递到Native层。// 直接发送 if (message.callbackId) { const callbackResponseId = message.callbackId; responseCallback = function(responseData) { //执行_doSend方法,传入Message,但是callback为null, 仅仅响应native层的回调。 _doSend({ responseId: callbackResponseId, responseData }); }; }
步骤9: 执行PieBridge.js中的_doSend方法
类似native层的
enqueueMessage
方法,如果此时需要native的回调,在js会生成一个唯一的callbackId
,并把这个callbackId
和reponseCallback
,存入到js中的哈希映射表中。并把callbackId
存入到Message中,但是这里是处理js回调到native层,所以reponseCallback
为null, 那么就是直接把message
push到js层的队列,最后发出第一个URL(piebridge://_queueHasMessage/)拦截,告诉Native层此时js层队列中有待处理的消息,提醒Native来获取消息。// sendMessage add message, 触发native处理 sendMessage function _doSend(message, responseCallback) { if (responseCallback) { const callbackId = `cb_${uniqueId++}_${new Date().getTime()}`; responseCallbacks[callbackId] = responseCallback; message.callbackId = callbackId; } //往发送消息队列sendMessageQueue中push消息 sendMessageQueue.push(message); //并通知native,队列中有消息了,发出URL拦截:piebridge://_queueHasMessage/ messagingIframe.src = `${CUSTOM_PROTOCOL_SCHEME}://${QUEUE_HAS_MESSAGE}`; }
步骤10: Native层通过webview中的shouldOverrideUrlLoading中拦截URL为:piebridge://_queueHasMessage/;触发MessageManager中的interceptMessage
在
MessageManager
中的interceptMessage
方法拦截指定URL的path,比如_queueHasMessage。然后执行flushJsMessageQueue
方法。
mBridgeWebView.setListener {
onUrlLoading { url ->
return@onUrlLoading mMessageManager.interceptMessage(url)//执行MessageManager中的interceptMessage
}
onPageFinished {
if (!mLoadFinished) {//页面加载完毕,将队列中所有消息全部分发出去
mMessageManager.flushMessageQueue()
mLoadFinished = true
}
}
}
//拦截JS中的消息
override fun interceptMessage(url: String): Boolean {
val decodeUrl = URLDecoder.decode(url, "UTF-8")
//拦截带返回值的URL
if (decodeUrl.startsWith(PieBridgeHelper.URL_RETURN_DATA)) {
handleMessageReturnData(decodeUrl)
return true
//拦截队列中有消息的URL
} else if (decodeUrl.startsWith(PieBridgeHelper.URL_HAS_MESSAGE)) {
flushJsMessageQueue()//执行flushJsMessageQueue
return true
}
return false
}
步骤11: Native层执行flushJsMessageQueue方法,通过组装一个jsCommand,通过loadUrl执行js中的_fetchQueue方法, 并注入一个callback用于回调获取_fetchQueue中的队列中数据。
调用callJsFuncCallback方法,执行jsCommand并把获取队列数据callback,以_fetchQueue方法名为key,callback为value存入Native层的callback哈希表中。
private fun flushJsMessageQueue() {
callJsFuncCallback(PieBridgeHelper.JS_CMD_FETCH_QUEUE, object :
IHandlerCallback {
override fun onCallback(data: String) {
handleMessageQueue(data)
}
})
}
private fun callJsFuncCallback(jsCommand: String, callback: IHandlerCallback) {
if (Thread.currentThread().isMainThread()) {
//最后通过loadUrl执行jsCommand: javascript:PieBridge._fetchQueue();
mListener.mCallJsFuncAction?.invoke(jsCommand)
}
//以_fetchQueue方法名为key,callback为value存入Native层的callback哈希表中
mCallbackMap[PieBridgeHelper.processFuncName(jsCommand)] = callback
}
步骤12: Js层会执行_fetchQueue方法
执行js中的
_fetchQueue
方法,会把当前js中的sendMessageQueue
待发送消息队列中所有的Message序列化成json,并清空js中的sendMessageQueue
待发送消息队列。最后把所有消息json作为path,拼接到返回数据URL的尾部: piebridge://return/_fetchQueue/[{data:xxx, callbackId:xxx},…]
// 提供给native调用,该函数作用:获取sendMessageQueue返回给native,由于android不能直接获取返回的内容,所以使用url shouldOverrideUrlLoading 的方式返回内容
function _fetchQueue() {
const messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
// android can't read directly the return data, so we can reload iframe src to communicate with java
if (messageQueueString !== '[]') {
bizMessagingIframe.src = `${CUSTOM_PROTOCOL_SCHEME}://return/_fetchQueue/${encodeURIComponent(
messageQueueString,
)}`;
}
}
步骤13: Native层会拦截js的URL: piebridge://return/_fetchQueue/[{data:xxx, callbackId:xxx},…]
Native层会拦截携带js层Message信息数据的url, 然后会调用native层的
handleMessageReturnData
//拦截JS中的消息
override fun interceptMessage(url: String): Boolean {
val decodeUrl = URLDecoder.decode(url, "UTF-8")
//拦截带返回值的URL
if (decodeUrl.startsWith(PieBridgeHelper.URL_RETURN_DATA)) {
handleMessageReturnData(decodeUrl)//用native层的handleMessageReturnData
return true
//拦截队列中有消息的URL
} else if (decodeUrl.startsWith(PieBridgeHelper.URL_HAS_MESSAGE)) {
flushJsMessageQueue()
return true
}
return false
}
步骤14: 执行handleMessageReturnData方法,从url中取方法名_fetchQueue以及js队列中的数据
url中取出的方法名
_fetchQueue
实际上是为了找到步骤11中,以_fetchQueue
存入到Native层callback映射表中对应的callback, 这个callback就是为了把队列中的数据回调出去。
private fun handleMessageReturnData(decodeUrl: String) {
//从url中取出方法名_fetchQueue
val funcName = PieBridgeHelper.processFuncFromUrl(decodeUrl)
//从url中取出js层队列数据data
val data = PieBridgeHelper.processDataFromUrl(decodeUrl)
//然后从native中的callback表中,找到方法名_fetchQueue为callback
val callback = mCallbackMap[funcName]
if (callback != null) {
//执行callback,把队列消息集合通过onCallback回调出去, 实际上就是回调到了callJsFuncCallback中的onCallback方法
callback.onCallback(data)
//最后移除方法名_fetchQueue的callback
mCallbackMap.remove(funcName)
return
}
}
步骤15: 执行mCallbackMap中找到的callback, 实际上就是执行了flushJsMessageQueue中的执行的callJsFuncCallback中的onCallback回调
private fun flushJsMessageQueue() {
callJsFuncCallback(PieBridgeHelper.JS_CMD_FETCH_QUEUE, object :
IHandlerCallback {
override fun onCallback(data: String) {
//会触发onCallback的回调,然后执行handleMesssageQueue方法。
handleMessageQueue(data)
}
})
}
步骤16: 最后执行handleMessageQueue方法
执行handleMessageQueue方法,把js层队列中的data转化成一个MessageList,然后遍历这个list去读每一个Message,如果Message中的responseId不为空,说明这是来自js层的回调消息,那么就会拿着这个responseId去native中的mCallbackMap找到对应的callback, responseData就是js层返回的数据,利用这个callback回调responseData即可。
private fun handleMessageQueue(data: String) {
val messageList: List<Message>
try {
messageList = convertMessageList(data)
} catch (e: Exception) {
e.printStackTrace()
return
}
if (messageList.isEmpty()) {
return
}
for (message in messageList) {
//如果responseId不为空,说明这是来自js层的回调消息,那么就会拿着这个responseId去native中的mCallbackMap找到对应的callback, responseData就是js层返回的数据,利用这个callback回调responseData即可
if (!message.responseId.isNullOrEmpty()) {
val resId = requireNotNull(message.responseId)
val callback = mCallbackMap[resId]
//执行callback回调触发onCallback方法,回调message.responseData
callback?.onCallback(message.responseData ?: "")
//最后移除在mCallbackMap中对应resId的callback
mCallbackMap.remove(resId)
continue
}
val callback: IHandlerCallback
val callbackID = message.callbackId
if (!callbackID.isNullOrEmpty()) {
callback = object : IHandlerCallback {
override fun onCallback(data: String) {
val tempMsg = Message(data).apply {
responseId = callbackID
responseData = data
}
enqueueMessage(tempMsg)
}
}
} else {
callback = object : IHandlerCallback {
override fun onCallback(data: String) {
//do nothing
}
}
}
val handler: IMessageHandler? = if (!message.handlerName.isNullOrEmpty()) {
mListener.mFindHandlerAction?.invoke(requireNotNull(message.handlerName))
} else {
DefaultMessageHandler
}
handler?.handle(message.data, callback)
}
}
步骤17: 最后执行calback方法,并获取到js层的数据
mPieBridge.callHandler("functionInJs", json, object : IHandlerCallback {
override fun onCallback(data: String) {
val jsonObject = JSONObject(data)
Toast.makeText(this@MainActivity, "来自Js的数据: ${jsonObject.getStringValue("id")}", Toast.LENGTH_SHORT)
.show()
}
})
- 3、Js 调用 Native的原理(带Callback): 同理分析
十一、X5WebView相关知识点、webview生命周期、addJavaScriptInterface注入的问题。
1、X5WebView初始化流程(X5源码是进行了混淆的)
1、X5WebView的初始化在子线程中进行的,主要的入口方法是
QbSdk.initX5Environment(context, cb)
public static void initX5Environment(Context var0, QbSdk.PreInitCallback var1) { if (var0 != null) { b(var0); D = new l(var0, var1);//混淆过后的代码,创建了一个l对象 if (TbsShareManager.isThirdPartyApp(var0)) { am.a().c(var0, true); } TbsDownloader.needDownload(var0, false, false, new m(var0, var1)); } }
2、进入
l
类,查看它的定义final class l implements TbsListener {//可以看到l类主要是实现了TbsListener监听,类似监听器Adapter l(Context var1, PreInitCallback var2) { this.a = var1; this.b = var2; } public void onDownloadFinish(int var1) { } public void onInstallFinish(int var1) { //注意:当TBS的下载完毕后,会执行QbSDK中的preInit方法,预加载 QbSdk.preInit(this.a, this.b); } public void onDownloadProgress(int var1) { } }
3、进入QbSdk中的preInit方法
public static synchronized void preInit(Context var0, QbSdk.PreInitCallback var1) { TbsLog.initIfNeed(var0); TbsLog.i("QbSdk", "preInit -- processName: " + getCurrentProcessName(var0)); TbsLog.i("QbSdk", "preInit -- stack: " + Log.getStackTraceString(new Throwable("#"))); l = a; if (!s) { //猜测这里传入一个MainLooper对象,var2是一个Handler对象,那么j就是一个Handler类 j var2 = new j(Looper.getMainLooper(), var1, var0); //然后再创建了一个var3对象,猜测这就是线程Thread子类对象,那么k就是一个Thread对象 com.tencent.smtt.sdk.k var3 = new com.tencent.smtt.sdk.k(var0, var2); //可以看到设置了子线程Thread的名称为tbs_preinit var3.setName("tbs_preinit"); //设置子线程的优先级 var3.setPriority(10); //启动这个子线程 var3.start(); s = true; } }
4、进入j类
//j就是一个Handler的子类 final class j extends Handler { j(Looper var1, PreInitCallback var2, Context var3) { super(var1); this.a = var2; this.b = var3; } public void handleMessage(Message var1) { switch(var1.what) { case 1: TbsExtensionFunctionManager var2 = TbsExtensionFunctionManager.getInstance(); QbSdk.a(var2.canUseFunction(this.b, "disable_unpreinit.txt")); if ("com.tencent.mm".equals(QbSdk.getCurrentProcessName(this.b)) && !QbSdk.c()) { QbSdk.j = false; } if (QbSdk.j) { bu var3 = bt.a().c(); if (var3 != null) { var3.a(this.b); } } if (this.a != null) { this.a.onViewInitFinished(true); } TbsLog.writeLogToDisk(); break; case 2: if (this.a != null) { this.a.onViewInitFinished(false); } TbsLog.writeLogToDisk(); break; case 3: if (this.a != null) { this.a.onCoreInitFinished(); } } } }
5、进入k类
//k是继承Thread的子类 final class k extends Thread { k(Context var1, Handler var2) { this.a = var1; this.b = var2; } public void run() { int var1 = am.a().a(true, this.a); TbsDownloader.setAppContext(this.a); TbsLog.i("QbSdk", "QbSdk preinit ver is " + var1); if (var1 == 0) { am.a().c(this.a, true); } TbsExtensionFunctionManager var2 = TbsExtensionFunctionManager.getInstance(); QbSdk.a(var2.canUseFunction(this.a, "disable_unpreinit.txt")); if ("com.tencent.mm".equals(QbSdk.getCurrentProcessName(this.a)) && !QbSdk.c() && !WebView.mWebViewCreated) { TbsLog.i("QbSdk", "preInit -- prepare initAndNotLoadSo"); am.a().b(this.a, true); o var6 = o.a(true); var6.a(this.a, false, false); bt var4 = bt.a(); int var5 = am.a().j(this.a); TbsLog.i("QbSdk", "QbSdk preinit coreversion is " + var5); if (var5 > 0) { var4.b(this.a); } } else { TbsLog.i("QbSdk", "preInit -- prepare initAndLoadSo"); boolean var3 = true; this.b.sendEmptyMessage(3); if (!var3) { this.b.sendEmptyMessage(2); } else { this.b.sendEmptyMessage(1); } } } }
2、WebView的生命周期
1、onResume: WebView此时为活跃状态时回调,可以正常执行网页的响应
2、onPause: WebView被切换到后台时回调, 页面被失去焦点, 变成不可见状态,onPause动作通知内核暂停所有的动作,比如DOM的解析、plugin的执行、JavaScript执行。
3、pauseTimers: 当应用程序被切换到后台时回调,该方法针对全应用程序的WebView,它会暂停所有webview的layout,parsing,javascripttimer。降低CPU功耗。
4、resumeTimers: 恢复pauseTimers时的动作。
5、destroy: 关闭了Activity时回调, WebView调用destory时, WebView仍绑定在Activity上.这是由于自定义WebView构建时传入了该Activity的context对象, 因此需要先从父容器中移除WebView, 然后再销毁webview。
mRootLayout.removeView(webView); mWebView.destroy();
3、addJavaScriptInterface注入的问题
我们都知道addJavaScriptInterface,是让Android Native可以注入一个对象供JS调用,是一种JS向Native通信的方式。但是这个会有一个坑,就是前端在使用这个注入的对象必须是当前页面加载完毕才能调用,否则会出现Java bridge method can't be invoked on a non-injected object
,也就是Java Bridge方法不能被调用,因为对象还未注入进去,所以也就是调用的时机在未注入完成之前。
源码中得到了说明, 注入的object可用的时机是在当前页面加载完毕后可用:
这个问题是出现在Native + Js实现沉浸式方案中出现的,Native注入一个对象供JS获取StatusBar高度,但是前端调用时机必须是在网页未加载完毕之前拿到拿到StatusBar高度,否则页面出来加载完毕后,再去调节StatusBar的高度会带来体验的问题。所以为了解决这个问题,我们可以使用PieBridge来解决,我们知道前端HTML是从上而下执行的,前端只需要把PieBridge.js放在HTML前面执行,客户端这边只需要往PieBridge注册一个获取StatusBar高度方法,这么PieBridge.js加载完毕后,就会去向Native获取高度,这样就能在webview加载完毕之前获取到。
@JavascriptInterface
public void changeStatusbarStyle(final int style) {
//还有就是@JavascriptInterface,js调用Native方法环境不是主线程而是一个叫做Java Bridge的子线程
mHost.getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
handleStyle(style);
}
});
}