原生动态化探讨

栏目: CSS · 发布时间: 5年前

内容简介:现在随着大前端的流行,Native小程序,RN,等看似打着原生旗号的动态化,但开发过程中都会发现非常非常的像在写前端,各种 margin padding align 等,尤其是各大框架看起来都在反复提一个词 FlexBox ,于是我们深入的聊聊在移动端,动态化与跨平台这两个词的发展。每一行代码本质上他就是一个字符串,他不具备任何的可运行的能力,之所以能被运行是因为他经过了一系列的编译,把一行行人能读懂的有逻辑有语意的字符串,变成了机器可以读懂的一条条指令集编译的最终结果想要执行,都得装载到运行环境里去,iO

现在随着大前端的流行,Native小程序,RN,等看似打着原生旗号的动态化,但开发过程中都会发现非常非常的像在写前端,各种 margin padding align 等,尤其是各大框架看起来都在反复提一个词 FlexBox ,于是我们深入的聊聊在移动端,动态化与跨平台这两个词的发展。

编译动态化

每一行代码本质上他就是一个字符串,他不具备任何的可运行的能力,之所以能被运行是因为他经过了一系列的编译,把一行行人能读懂的有逻辑有语意的字符串,变成了机器可以读懂的一条条指令集

  • 输入:代码字符串
  • 编译:(简单介绍一下不展开 http://awhisper.github.io/2017/02/26/扯淡:大白话聊聊编译那点事儿/
    • 编译前端
      • 词法分析 一句话解释:识别字符串中的语言关键字
      • 语法分析 :识别循环,判断,跳转,调用等代码逻辑
      • 生成抽象语法树 AST :将代码字符串转化为机器可理解可遍历可处理的语法树
    • 编译后端
      • 生成中间码 IR :将语法树向可执行结果产物进行转化,先产生个中间产物
      • 生成结果 :将中间码根据个平台的差异,生成不同的运行结果
  • 输出:平台可运行的结果
    • iOS:可执行二进制 Mach-O 文件(汇编代码装载进入内存后即可执行)
    • 安卓:JVM的字节码(在任何有JVM的平台中都可以执行)

编译结果动态化

编译的最终结果想要执行,都得装载到运行环境里去,iOS是直接装载到内存上,而安卓是加载到JVM虚拟机里,而这个加载过程天然是支持“动态”加载的,并且这种动态化的开发模式是非常贴近原生的。

  • 安卓:插件化技术,dexloader 加载
  • iOS:动态链接库技术,dlopen 加载

安卓我不了解就不细说了,但iOS确实值得咬文嚼字一下,dylib 的全程叫动态链接库,我们有时候自己做一些通用组件库的时候都可以选择创建一个“静态链接库”又或者“动态链接库”,差异取决在是否在app启动截断就第一时间加载。但次动态指的是动态“链接(加载)” 而非 “动态更新”。

静态库中的代码都会与主程序编译到同一个可执行文件中,如果多个程序引用了同一个静态库,那么这个静态库是会在多个程序的可执行文件中存在多个副本。而动态库他的本质是希望让多个可执行文件间共用代码段,在链接装载期间,通过把独立于主程序可执行文件之外的 dylib 链接到主程序的虚拟地址上,故名:动态链接

但既然是动态链接,那么在本次加载的时候通过网络下载了最新的 dylib 文件,在下次加载最新文件,自然也能做到 “动态更新”,下面看一个在运行期间用代码,手动链接 dylib 的例子

#define Dylib_PATH  "/System/Library/PrivateFrameworks/xxxxx/xxxxx/xxxxx"

- (void)dynamicLibraryLoad
{ 
  // dlopen 去动态加载 dylib
  void *kit = dlopen(Dylib_PATH,RTLD_LAZY);   
  
  // 在运行期动态加载的dylib应该如何调用里面的代码?  OC有运行时与反射
  
  Class pacteraClass = NSClassFromString(@"DynamicOpenMenth");
  if (!pacteraClass) {
      NSLog(@"Unable to get TestDylib class");
      return;
  }
  NSObject *pacteraObject = [pacteraClass new];
  [pacteraObject performSelector:@selector(startWithObject:withBundle:) withObject:self withObject:frameworkBundle];

  // 只有OC的代码才能动态调用么?C代码一样有办法 dlsym 通过函数符号获取动态加载后的函数地址,进行调用
  
  NSString *imsi = nil;
  int (*CTSIMSupportCopyMobileSubscriberIdentity)() = dlsym(kit, "CTSIMSupportCopyMobileSubscriberIdentity");
  imsi = (NSString*)CTSIMSupportCopyMobileSubscriberIdentity(nil);
  
  // 卸载动态库
  dlclose(kit);
}

只可惜这条看似最佳的动态化的路被苹果堵死了,dlopen 这个函数在 debug 包下运行以上代码,动态加载畅通无阻,但在 release 的包上,就会被苹果加入签名校验逻辑,凡事没经过签名的dylib/framework,都会动态链接失败,所以这条路在 iOS 上是被封死的,但是在安卓上,各种插件化的方案还在继续

编译结果跨平台

本身 Java 就被设计为一种一次编译处处运行的语言,只要对应平台有 JVM 这个虚拟机运行环境,换句话说跨平台的差异被 JVM 给打平了,但很可惜 iOS 平台并没有选择 JVM ,所以也就不跨到了 iOS 上。

那么剩下的跨平台的语言就是 C/C++ 了,安卓与iOS都支持用C与C++的编译产物,在iOS上 OC 与 C 和 C++ 几乎是无缝支持,而在安卓上需要 JNI 来进行桥梁与 java 互通。早年间流行的游戏引擎 cocos2dx 就是用 C++ 进行的逻辑与渲染(lua容后再表),Flutter的渲染平台就是用 C++ 写的 skia 渲染引擎做的,安卓与iOS两个平台其实都存在很多知名的库或者本地算法,底层是用 C++ 实现的。

本质上:编译后台的设计分为,先编译出中间码,再由中间码根据平台差异编译成最终结果,本身就是一个为了跨平台而做的设计“增加一个中间层”,但由于很多语言在发展过程中,不同语言的作者在做开发语言编译器的时候,使用的IR中间码,并不是统一的一套,所以设计很丰满,现实很骨干,IR中间码并没有完全解决跨平台的问题。

Web技术动态化 - “不要写死”

我们在开发的过程中即便是纯native开发,也经常会伴随着一些开发理念“不要写死”

  • 界面的文案?不要写死,服务器下发
  • 界面的文案的样式?不要写死,阴影,加粗,斜体,下划线服务器下发
  • tableView 的Cell?不要写死,七八种cell模板,服务器下发啥渲染啥
  • 程序逻辑?不要写死,根据服务器返回的type开关,看情况跳转
  • 功能模块?不要写死,看开关可以整体隐藏关闭

在编译运行的过程中,代码字符串被编译成了可执行的编译结果,在对应平台上运行。在“不要写死”这个思路中,描述配置信息等json字符串,被通过网络在程序运行的过程中,实时根据配置改变运行结果。

所有的这些“不要写死”,其实是一种可以通过网络下发的描述性值的信息,以json形式,从代码逻辑通过读取这些描述信息,去改变预先准备好的功能逻辑,从而做到功能改变。甚至有些功能页面例如“账单详情”,他的现在几乎进入了高度后端可配的状态,面对不同商家不同种类交易,完全无需前端迭代,纯靠后端强大的配置平台即可完成频繁的各业务方接入需求。

原则上讲,如果我们的描述配置语言设计的足够全面,应用程序中固化的那部分识别描述配置并且执行的固有能力也足够全面。既能考虑到任意当前已规划的需求,以及胜任未来的功能需求,那么我们的描述配置语言,无形中其实就是一种动态更新,这种描述配置语言其实也就形成了 DSL 领域专用语言,在你的应用程序配置体系中专用的描述语言。

布局引擎 - 界面动态化

在界面这一块确实就存在这种几乎能够完美涵盖所有界面需求的“DSL”设计,也存在着能够解析这种完美“DSL”设计的native固定代码“布局引擎”,从而做到一套布局引擎的程序代码,根据输入的 “DSL” 不同,呈现出风格迥异的页面。

布局引擎并不是因为动态化的诉求而产生的,本质上是为了解决屏幕多尺寸适配的问题,为了能用一种“通用”的描述方式,通过传入的窗口区域大小,实时的运算出每一个子元素在当前窗口里的 x y w h 绝对坐标,这个过程便是布局算法

类似的布局算法有很多种,但可以确定的是,浏览器内核中的布局引擎是眼下最完美的,并且还在被 W3C 以及浏览器厂商不断的完善,浏览器内核的布局引擎的输入是 HTML 这种文件来表达界面元素层级描述 CSS 这种文件来表达布局约束信息与样式信息,并且支持绝对布局,相对布局,盒子模型,弹性盒子(知名的 FlexBox),网格布局等多种布局算法相互之间嵌套使用~本质上是一种多种算法可扩展的布局引擎。(后面会略微详细介绍一下布局算法)

原生动态化探讨

这种布局引擎本质上还是 native 的(浏览器内核是用c++)写的,如果不是直接使用 WebView,也有很多有着几乎一样思路的 native 布局 SDK

  • iOS 的 AutoLayout :iOS中的 xib 布局文件,本质上就是一种树型的 XML,它里面包含着界面层级信息与样式信息,经过原生代码 initWithNibName: 来读取这种 xml 最终生成界面。而如果不使用 xib,而是代码创建约束,那么原生 VFL 其实本质上也是一种DSL,而他内部的算法是通过计算约束 N 元一次方程组得出最终渲染坐标的,其算法性能出了名的差,在很多情况下即便原生 autolyout 非常的卡(在iOS 12以后得到优化)
  • 安卓的 XML 写界面:安卓的布局也很像浏览器内核,他也支持用 XML 去写,并且同样支持几种布局,从而满足丰富的界面展现需求
  • DTCoreText:基于 iOS原生 CoreText 排版库的上层封装,可以识别 HTML 从而直接渲染出native的
  • RN / Weex 的渲染框架:实际上也是 HTML/CSS 来通过 FlexBox 弹性盒子这种布局算法,构建出Native界面
  • Texture 框架:原名 AsyncDisplayKit ,FB 出品,也是基于 FlexBox 弹性盒子这种布局算法,其实还有 YogaKit , ComponentsKit 等,归根结底还是来自 FB 开源的 FlexBox 布局算法库 “Yoga”(我们的鸟巢也是来源自 yoga 同一个布局内核)

所以本质上,HTML+CSS 这种网页的界面编写模式,和安卓原生的 xml 写界面,和 iOS 原生的 xib 渲染界面本质上是没区别的,甚至有时候还比原生快(iOS Autolayout被人喷太慢了) ,浏览器与H5慢是多方复杂原因共同导致的,但在布局渲染这块,Web的技术体系在UI的描述能力以及灵活度上确实设计得非常优秀,越来越多的原生动态化方案,在渲染这块都还离不开这一套技术体系

解释执行 - 逻辑动态化

单纯通过界面动态化,这样我们做出来的这样的一个动态App,一个界面的 DSL 中给可点击交互的元素加上一个 scheme 的路由属性,每当这个元素被点击的时候,就跳转这个路由,打开一个新界面,而新的界面又是全新的一个新下载下来的 DSL,从而实现了纯展示型页面跳转的 “动态” App。仔细分析一下,这其实就是早期最原始的浏览器的网页,每个网页由一个 url 去下载 html/css ,然后布局展现页面,每个元素的点击交互的效果是跳转一个新的 url,所以可以认为这样的一种 “动态界面” App,是最纯正的网页技术,虽然他是 Native 的。

但这种动态远不是我们所希望,我们希望可以在不同的交互与生命周期下进行:

  • 发出一个网络请求,获取最新最全的数据
  • 进行本地数据存储读取,数据持久化
  • 针对数据进行多方逻辑判断,根据逻辑结果,呈现给用户不通的表现
  • 等等

纯界面动态化是不可能满足这种需求的,俺着我们的上面探讨的思路,我们需要一种“能够描述逻辑的表达式”,以字符串的形式下发到客户端,然后通过客户端内置的一套固定的“运行环境”,来展现出不同的运行结果。

对比一下界面动态

  • “能够描述界面的表达式-字符串”:就是一种描述语言 DSL (领域特定语言)
  • “运行环境”:执行描述表达式需要一套用 native 代码编写的布局引擎,包含各种布局算法

逻辑动态会复杂的多

  • “能够描述逻辑的表达式-字符串”:单纯是 DSL 已经无法满足需求,我们需要正经代码编程的语言(脚本语言)
  • “运行环境”:编程语言的编译结果能否再任何平台下直接执行?我们需要的是一个用c编写的,脚本语言(JS/Lua)的虚拟机

脚本语言的虚拟机会严格遵照编译原理中的编译前端,即便是主体程序运行期间输入一段目标编程语言的代码逻辑,它也会先经过词法/语法分析,从而生成抽象语法树 AST,最终也会把程序转化为一条一条的运行指令,在虚拟机运行期把编译出来的指令集按顺序执行完并最终得到执行结果。(推荐一本书:《 Lua设计与实现》

而脚本语言的内存控制统一被虚拟机进行整体控制,随着指令集在执行的过程中进行对象与内存空间的分配与管理,因此脚本语言的内存是跑在虚拟机的一个上下文之中,这个上下文与原生内存是隔离开的。

大家上大学的时候是否在学习计算机数据结构的时候,被老师要求做一个计算器,输入一个 “1+2*3-4/5”这种字符串,用栈这种数据结构去解析这个字符串运算出结果?这个过程就非常的像解释执行

词法分析:使用栈或者二叉树,一个字符一个字符的读取,读到数字压栈,读到符号/括号,压入符号栈

语法分析:根据你规划的 + - * / 的运算符优先级,以及括号的优先级,构建一个运算树

抽象语法树AST:这个树就可以执行出运算结果

原生动态化探讨

目前最广泛使用的解释执行编程语言就是 JavaScript ~

Binding:

脚本语言能运行,只能执行逻辑,for 循环 / if 选择判断 / 函数方法 / 执行回调 等语言逻辑,但是想要命令设备去执行一些操作,还是需要调用原生平台的各类 API ,比如磁盘读写,比如网络请求,比如图形渲染。 脚本语言 JS 再怎么编写各种三方js库,也都只是在虚拟机内的上下文中进行运行,无法操作设备。想要让 js 代码所执行的虚拟机能够操作原生环境的硬件,就得构建一个桥梁叫做 Binding(对,其实就是是jsbridge,但 Binding 是更科学的叫法)

  • js 是没有发起网络请求的能力的,浏览器之所以能用 js 写 ajax 网络请求,是因为本身浏览器用 c/c++ 写了网络模块,然后把这个模块 binding 给了 js ,并且在js里封装成了 XMLHttpRequest 对象与 API
  • js 是没有能力改变界面渲染的,是的没错!浏览器内布局引擎与脚本引擎是2个独立的模块,布局引擎纯native的,js是没有能力去直接访问的,但是浏览器专门把布局引擎 binding 给了js,并且在 js里封装成了 Document 对象与 Dom 操作 API
  • js 是没有能力进行本地存储的,浏览器之所以能够用 js 写 localstorage ,就是因为浏览器把本地建值存储的模块 binding 给了 js ,并且在 js 里封装成了 localstorage API
  • 等等

原生动态化探讨

我们平时给webview 写的各种 jsbridge ,其实就是浏览器里面对js 的 binding 概念的延伸,只不过浏览器里面 binding 的各种能力,都是经过 W3C 协会沉淀了十几年的标准,必须具备通用型和可扩展性,不是你想要啥功能都能给你 binding上 的。但我们做客户端的就有这个优势,在我们的自主 App 内,我们就可以利用 native 开发,自行扩展 js 的任意 native 业务能力。

这也就是所谓的前端开发受限于浏览器,浏览器不支持,前端就做不到,但客户端开发本身就在开发浏览器,前端想做而做不到的我们都能补上。

Tips:

问题:如果说所有 native 能力全都 binding 给 js,把每一个 iOS 的系统 Api(可能几万个)都 binding 给 js,那么是不是就可以直接用纯 js 直接写原生 app 的全功能了?把图形渲染能力也 binding 给 js 是不是就不需要 html/css 外加一个复杂的布局引擎,直接用 js 调用 binding 好的 UIView initWithView , addSubview ,就能写出任意的界面了?

回答:说的没错,但这样也是有代价的,每调用一次 binding 的 api 都是一次跨越上下文的接口调用,这个过程非常的耗时过程,在JS上下文的对象,需要进行序列化,然后通过一个通道与原生内存的上下文进行通信,然后在原生内存中还得反序列化才能会被正常的原生代码进行处理(js 与 lua 这个通道的底层处理都是在 c 这一层通过 data buffer 的压栈出栈处理传递数据),所以理论可行的用 js / lua 直接编写原生 App 性能还是存在问题的

  • 在JSPatch 没被封杀的时候,不仅拿 JSPatch 来 hotfix bug,直接用 JSPatch 构建全新的动态更新的功能页面,就是这个原理,只不过区别是 JSPatch 不需要把几万个 iOS 系统原生 Api 都 binding 了,因为 iOS 有 runtime,只需要把 Objc_msgSend 和 NSInvocation 给 binding 了,就可以做到用 js 代码,调用任意原生 iOS API,安卓也有反射,安卓也可以做到只用 js 代码,写出并调用任意安卓原生 API (以前我们写过在 app 黑盒运行期的调试工具,可以输入任意代码,动态调试安卓 与 iOS app,非常方便在bug现场的情况下任意调试代码,调试内存,快速追踪问题)

  • C++的游戏引擎 cocos2dx-lua 也是这个思路,c2d引擎小组的成员真的把一整个引擎的所有 C++ API 统统 binding 给了 lua,所以在游戏圈里,大量用纯 Lua 开发游戏,只有在引擎C++控件不满足需求的时候,才需要少量C++开发的开发模式。

Web技术思路下的原生动态化与跨平台

原生动态化探讨

布局引擎 + 脚本引擎,构成了Web技术最重要的2大模块,同时也是浏览器内核最重要的2个核心,而后续所有的非编译结果的那种动态化方案,全都是Web技术思路下而产生的动态化方案。

并且这些方案本来就是跨平台的,底层用c去实现内核,实现脚本引擎,只要底层环境在各个平台都有,那么上层的 DSL 与 JS 就可以跨平台。

  • WebKit:如上图,输入 HTML / CSS / JS ,由浏览器内核实现
  • ReactNative:
    • 输入的是 JSX 但实际上,也是HTML CSS JS 混合在了一起
    • 布局引擎是 c 写的 FB 的开源 Yoga Flexbox 布局引擎
    • 渲染是通过 binding 模式直接调用各平台原生渲染 API ,构造 View ,Add View
    • 通过 javascriptCore 这个开源 js 解析引擎来作为虚拟机,运行构建 js 上下文
    • 通过把各种丰富的原生能力,原生界面组件,binding 给 js,从而丰富各种原生能力能够动态调用
  • 鸟巢:
    • 输入是 HTML / CSS / JS ,但是会在服务器端被分析合成一个 json 树结构模板,下发到客户端
    • 客户端SDK输入是 json 树结构,遍历进行布局与渲染
    • 布局引擎是 c 写的 FB 的开源 Yoga Flexbox 布局引擎
    • 渲染是通过 binding 模式直接调用各平台原生渲染 API ,构造 View ,Add View
    • 通过 dukTape 这个开源嵌入式 js 解析引擎来作为虚拟机,运行构建 js 上下文
    • 接口 API 上提供扩展,让接入鸟巢SDK的业务方也很方便能 binding 各种 native 能力

鸟巢工作流程分析

原生动态化探讨

鸟巢的官网介绍的是开发的过程中可以用 HTML + CSS + JS来开发,其实鸟巢的工作机制上和浏览器内核的原理一模一样,也是将 HTML & CSS 在内核中整到一个树上,并且把 JS 整体输入虚拟机,但鸟巢有一些差异。

服务器远端处理Dom

  • Dom 流程差异:
    • 鸟巢:解析识别 HTML & CSS 的过程不发生在客户端,是在服务端/ 开发阶段 IDE 中进行,最后把界面元素与样式合并成一个json树状结构:JSON格式 Dom 下发给客户端,行程树状内存 Dom 对象
    • WebKit:解析识别 HTML & CSS 的过程发生在客户端,网络模块只下载 HTML CSS 源码,解析识别后最终生成 Dom 树状结构:内存中树状格式的 Dom 对象
  • JS 流程的差别:
    • 鸟巢:JS代码最后被一起放入了 JSON格式的 Dom 之中,当下载到客户端本地的时候,从 Dom 中取出来直接输入虚拟机的上下文运行环境之中
    • Webkit:支持解析 HTML 中的 script 标签,也支持直接加载 js 代码,网络模块下载到的 js 源码,会直接输入虚拟机的上下文运行环境之中

简单的说,鸟巢是先构建 Dom 后下载整个 Dom树,而 WebKit 是先下载所有代码资源文件,后在客户端本地构建 Dom树,在鸟巢里一个模板id 一个包,一个网络链接下载下来,并且辅助以 native 的缓存与更新机制。在 WebKit 里无论是 html css 还是 js,都可以按文件拆分,每个文件都可以由浏览器独立进行下载,并且按着浏览器的缓存机制按着 Header 的 cache-control 等信息进行缓存控制。

客户端本地内核模块

原生动态化探讨

上面是简单的画了一下鸟巢的iOS客户端代码结构图,总共分2个 Bundle ,一个是鸟巢的主模块,一个是鸟巢核心依赖的3个三方 C 库。

鸟巢另外包含2个 bundle :

  • ApAutoPage
  • ApAutoPageBiz

这篇文章主要讨论鸟巢原理的机制,这 ApAutoPage 的封装主要是介绍把鸟巢的能力封装成类似 Nebula 容器一样的 MicroApplication ,AppId 统一为 20001002,通过这个 AppId ,可以完全0客户端迭代,纯在鸟巢的平台开发一个纯鸟巢的应用页面

因此可以认为 ApAutoPage 是 BirdNest 的上层封装,关注鸟巢内核本身原理的时候,只需要关注 BirdNest Bundle 源码就好了

进行一个简单的类比,BirdNest 就相当于 WebView 而 ApAutoPage 就相当于 Nebula

  • BirdNest

    • View Builder: 算是作为整个鸟巢内核的使用入口,提供一体化的模板处理入口,内部包含 Template Manager 的模板管理模块,而 View Builder 经过一体化的处理,产出结果是 Document 对象

      • Template Manager: 是鸟巢模板的管理模块,主要包括模板的下载模板,下载队列控制,版本更新判断,模板读取。鸟巢模板后台提供灰度能力,统一由服务器下发模板的更新信息,根据不同的网络环境以及本地模板状态判断,模板更新流程
  • FBDocument: 是鸟巢的Dom 对象,本身继承自 ViewController ,统一管理模板生成出来的的 VM - Dom 树,由 Dom 树进行布局生成的 View ,以及 各个 View的 UI 交互触摸事件的响应

    • FBView: Dom树会经过布局流程算出每一个子 View 的 frame,然后通过 getView 的方法,按着计算出来的 frame ,一个个创建出对应的 native View,然后汇总成最终的用于展示的 Native 界面,这里会根据 Div 的种类,创造出 Button Image Input Switch Vedio 等,并且支持横向扩展
  • BirdNestBase

    • layout:FBDocument内部进行布局流程的时候,主要依赖的 C++ Flexbox 布局算法,从代码里看来自 RN 与 Weex 都使用过的 Yoga layout.c 代码片段,但已经被二次改造过

    • duktap:一个轻量级嵌入式 js 解释引擎,不像RN 与 Weex 使用的是 JavascriptCore 这个开源 js 引擎,鸟巢使用的是一个更轻量级的

    • fbJSON:服务器生成的 JSON 格式的 Dom 结构,也就是模板,需要进行 JSON 解析,这个是所依赖的底层 JSON 解析库

客户端本地更新流程

  • 主动拉:业务的服务端集成mobiletms的近端jar包,由客户端发起请求,mobiletms近端包返回templateJson模板信息,客户端把templateJson给鸟巢sdk,由鸟巢sdk判断是否需要更新模板。
  • sync推:通过sync机制,往客户端全局推送模板。(由于sync全局推送方式,不符合需求发布的三板斧原则,该功能目前只开放于紧急bug修复,不适用于需求变更,所有需求变更都需要走近端接入方式)

原生动态化探讨

Dom树本地工作流

View Builder 的主要工作就是构建一整个 FBDocument ,而 FBDocument 前边介绍了,他是整个鸟巢界面的核心,本身包含着整个界面的 ViewModel 数据,也就是 Dom 树

这就是 FBDocument 的初始化流程

- (id)initWithHtml:(NSString*)html withData:(NSString*)data andDelegate:(id)delegate andFallbackDelegate:(id)fallbackDelegate andOption:(long)option docName:(NSString *)docName {
    
    self = [super init];
    
    // 一大堆 property 对象的 初始化
    self.xxx = xxx;
    self.xxx = xx;
  	// 等等...
    
    //主要是 fb_node.c 对象,每个node 就是一个dom节点,需要计算整体布局就递归调用 layout.c 的算法
    if (!_core) {
        //构建排版内核对象
        _core = fb_core_new(); 
        //设置布局计算完毕后的回掉
        _core->core_layout_notify = core_layout_notify; 
        //设置 meta 信息处理的外部回掉
        _core->tpl_content_handler = platform_content_handler;
      	//设置整个 Dom 处理完毕后的回掉
        _core->core_load_finish_notify = core_load_finish_notify;
        //保存当前 Document 对象指针给 core ,便于 core 的一些处理
        _core->context = (__bridge void *)(self);
        
    }
    // 开始加载 模板 
    // - 传入数据 json 形态的 dom 数据(html参数)
    // - 传入数据 json 形态的 data 数据(data参数)
    fb_core_load_l(_core, [html UTF8String], [data UTF8String], false, option);
    return self;
}

而这其中最核心的就是 fb_core_load_l 这个方法,这个方法干的事情比较多,精简一下代码,然后解释一下流程,对关键环节进行逻辑标注

  • 解析 data json
  • 解析 html json 为 body header
  • 构建 js 虚拟机上下文,把 body header data中的 js 标签里的 js 代码,执行进入虚拟机
  • 用layout.c的flexbox排版算法,递归整个 body 的 Dom 树,算出每个ui元素的 frame坐标
  • 发出load finish事件,触发页面渲染构建
bool fb_core_load_l(fb_core_t* core,
                  const char *page,
                  const char *data,
                  bool js_debuggable,
                  const long flags) {
  
    // ... 省略部分代码
  
		// 解析传入的 data json 数据
    if (data != NULL) {
        core->last_data = fbJSON_Parse(data);
    }
  
    // ... 省略部分代码
  
	  // 解析传入的 html json 数据
    core->html = fb_parser_json(core, page);

    // ... 省略部分代码

    // html 解析结果里面分拆出 body 与 head
    core->head = fb_node_by_tag(core->html, FB_tag_head);
    core->body = fb_node_by_tag(core->html, FB_tag_body);

    // ... 省略部分代码

    //### 如果head或者body为空,那么就无法正常渲染了,直接返回失败
    if (!core->head || !core->body) {
        return false;
    }

    // ... 省略部分代码
  
    //无 js 的鸟巢业务,即精简模式下不构建 js 虚拟机,非精简模式,构建 js 虚拟机上下文
    if (!core->isLiteMode) {
        fb_core_init_script_engine(core);
    }
  
    // ... 省略部分代码

  	// 非精简模式下,遍历 data  body  head 中的 script 标签数据,读取出js代码,输入 duktape 虚拟机上下文
    if (!core->isLiteMode) {
        const bool dataIsJson = fb_tools_is_json(data);
        fb_script_execute_string_l(core->scriptEngine, data, flags);
        
        for (int i = 0; i < core->head->cssNode.children_count; i++) {
            fb_node_t *node = core->head->subNodes[i];
            if (node->tag == FB_tag_script) {
                
                const char *src = fb_node_get_attr(node, "src");
                if (src) {
                    char *data = fb_platform_load_file(node->core, src);
                    fb_script_execute_string(core->scriptEngine, data);
                    if (data && strlen(data) != 0) {
                        free(data);
                    }
                }
                
                const char *text = fb_node_get_attr(node, "text");
                if (text) {
                    fb_script_execute_string(core->scriptEngine, text);
                }
            }
        }

        // ... 省略部分代码        
      
        //查看整个节点中是否包含 js 的 onload 事件,如果有则执行 onload
        fb_script_cb_t *onload = fb_node_get_event(core->body, "onload");
        if (onload) {
            fb_script_execute(core->scriptEngine, onload, NULL);
        }
        
        //查看整个节点中是否包含 js 的 onreload 事件,如果有则执行 onreload
        if (data && dataIsJson) {
            fb_script_cb_t *onreload = fb_node_get_event(core->body, "onreload");
            if (onreload && data) {
                core->hasOnReload = true;
                fb_script_execute_javascript_with_json(core->scriptEngine,
                                                       onreload->js_callback,
                                                       data);
            }
        }
    }

    // ... 省略部分代码 
  
    //### 最终layout一次,递归整个树,让每个 dom(即 node 节点对象,都经过layout.c的算法计算好了frame)
    fb_core_layout(core);
  
    // ... 省略部分代码 

    // 发送 Document Load Finish 回掉,触发后续的渲染流程
    if (core->core_load_finish_notify) {
        core->core_load_finish_notify(core);
    }

    return true;
}

FlexBox布局算法简单介绍

上面介绍的代码流程里 fb_core_layout 就是进行布局计算的算法,可以仔细看他的源码,他的核心就是调用 layout.c 这个 BirdNestBase 里面的布局库的 layoutNode 方法,传入了 core-> body -> cssNode 的树的根节点,然后这个算法会层层递归完整个树,计算每个节点的UI元素应有的 frame

layout.c 代码本来是源自 ReactNative 里面的源码,后续 Weex 以及聚划算的 LuaView 都曾经使用过 layout.c 这个纯 FlexBox算法的开源库,并且进行了自己的优化修改。而 FaceBook 也在不断优化,重新整理封装独立开源成了名为 Yoga 的库。

无论是哪一家的 FlexBox 的算法原理都差不多,有兴趣的可以详细了解一下,主要核心就是 FlexBox 把界面划分为横纵两个轴,视 css 的值来决定主次,然后在算法里会沿着主次轴,对内部元素进行弹性填充计算

参考资料: https://halfrost.com/weex_flexbox/

原生动态化探讨

这里我就简单的过一下,不深入详解 FlexBox 算法了

  • 递归节点,判断是否使用缓存,还是重新layout
    • 计算盒子边框边距的基础参数
    • 针对主侧轴,分别判断在边距边框下的可用size
    • 沿着主轴遍历子视图,摆放子视图
      • 子视图是否可拉伸,取决于递归子视图直到子视图拥有最大或最小或确定的尺寸
    • 在主轴上通过 flex 的一些 css 属性来确定可受弹性变化的子视图的位置微调
    • 在侧轴上通过 flex 的一些 css 属性来确定子视图的位置微调
    • 确定子视图绝对布局坐标 frame

整个算法由7个主要的大循环组成,不详细分析代码了,已经给出参考链接

FlexBox弹性盒子布局算法,只是 WebKit 布局能力的一部分,可以说 FlexBox 的布局能力只是 WebKit的子集

所以可以认为鸟巢(还包括 RN Weex 等所有用FlexBox的框架)的界面布局表达能力,只是WebView的界面布局表达能力的子集,但如果这个子集足够满足大部分的需求,那么也可以满足业务需要

原生页面的创建与渲染

当 layoutNode 布局计算结束后,会触发 core->core_load_finish_notify(core) 回掉,就会回到 FBDocument 这个类里去调用 updateLayout 这个 OC 方法,进行渲染

渲染过程依赖一个核心队列 _core->actionSeq 这个队列其实是在服务器构建 JSON 化的 Dom 数据的时候,就被服务器创建好了,简单的说一下这里面的内容其实就是根据 Dom 树的层级,生成一个渲染指令队列,例如

  • 创建一个根视图 RootView
  • 创建一个子视图 AView
  • 更新子视图 AView 的属性,会用到布局计算的 frame结果,来更新frame属性
  • 将子视图 AView 添加到 RootView 上
  • 创建一个子视图 BImage
  • 更新子视图 BImage 的属性,会用到布局计算的 frame结果,来更新frame属性
  • 将子视图 BImage 添加到 RootView 上

所以我们来分析一下原生页面创建与渲染的代码工作流程

- (void)updateLayout
{
    //循环指令队列,执行每个指令
    for (int i = 0; i < _core->actionSeq->length; ++i) {
        // ... 省略部分代码 
        
        //### 指令已经无效,跳过
        if (op->node == NULL) {
            continue;
        }
        
        //判断指令类型
        switch (op->op) {
                
            //### 创建view
            case DOM_CREATE: {

                FBView *view = nil;
                
                //创建 root View
                if (op->node->tag == FB_tag_body) {
                    
                    _root = [[FBView alloc] initWithNode:_core->body
                                                withDocument:self
                                                  withView:nil];
                    
                    view = _root;
                }
                else {
                    // 创建其他嵌入 View
                    // 识别子View 的native ui 类型
                    switch (op->node->tag) {
                    
                        // ... 省略部分代码 
                        
                        //容器空 View
                        case FB_tag_div: {
                            view = [[FBView alloc] initWithNode:op->node
                                                   withDocument:self
                                                       withView:nil];
                            break;
                        }
                        
                        // ... 省略部分代码 
                        
                        //原生勾选框 View
                        case FB_tag_checkbox: {
                            view = [[FBCheckbox alloc] initWithNode:op->node
                                                       withDocument:self
                                                           withView:nil];
                            break;
                        }
                        
                        // ... 省略部分代码 
                        
                        //原生开关 View
                        case FB_tag_switch: {
                            view = [[FBSwitch alloc] initWithNode:op->node
                                                       withDocument:self
                                                           withView:nil];
                            break;
                        }
                        // ... 省略部分代码 
                        
                        //原生文本标签 View
                        case FB_tag_label: {
                            
                            NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
                            view = [self.dictLabel objectForKey:key];
                            if (view == nil) {
                                view = [[FBLabel alloc] initWithNode:op->node
                                                        withDocument:self
                                                            withView:nil];
                                
                                [self.dictLabel setObject:view forKey:key];
                            } else {
                                [view associateNode:op->node];
                            }
                            break;
                        }
                        // ... 省略部分代码 
                        
                        //原生图片 View
                        case FB_tag_img: {
                            
                            NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
                            view = [self.dictImg objectForKey:key];
                            if (view == nil) {
                                view = [[FBImg alloc] initWithNode:op->node
                                                        withDocument:self
                                                            withView:nil];
                                
                                [self.dictImg setObject:view forKey:key];
                            } else {
                                [view associateNode:op->node];
                            }
                            
                            break;
                        }
                        // ... 省略部分代码 
                        
                        //原生按钮 View
                        case FB_tag_button: {
                            view = [[FBButton alloc] initWithNode:op->node
                                                  withDocument:self
                                                      withView:nil];
                            break;
                        }
                        
                        // 可以横向扩展各种原生 View
                        
                        // ... 省略部分代码 
                        
                        default: {
                            
                            break;
                        }
                    }
                }
                
                // ... 省略部分代码 

                break;
            }
                
            // 把一个 View add 到另一个View 成为子 View
            case DOM_ADDVIEW: {
                FBView *subView = [self findViewByNode:op->node];
                FBView *superView = [self findViewByNode:op->node->superNode];
                
                if (superView && subView) {
                    [superView addSubview:subView];
                }
                else {
                    //assert(false);
                }
                break;
            }
                
            // 删除一个 View
            case DOM_DELETEVIEW: {
                FBView *subView = [self findViewByNode:op->node];
                [subView removeFromSuperview];
                
                //### label 需要从 cache中移除
                unsigned tag = op->tag;
                if (tag == FB_tag_label) {
                    NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
                    [self.dictLabel removeObjectForKey:key];
                }
                
                //### img 需要从 cache中移除
                if (tag == FB_tag_img) {
                    NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
                    [self.dictImg removeObjectForKey:key];
                }

                [self removeView:op->node fbView:subView];
                
                break;
            }

            //### 更新 view 的 rect,rect 来自 layout.c 的计算结果
            case DOM_UPDATE_RECT: {
                FBView *view = [self findViewByNode:op->node];
                [view updateRect];
                break;
            }
                
            //### 更新 view 的其他 css属性
            case DOM_UPDATE_CSS: {
                FBView *view = [self findViewByNode:op->node];
                
                NSString *style = [NSString stringWithUTF8String:(char*)op->param];
                NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
                if (arrKeyAndValue.count == 2) {
                    NSString *key = [arrKeyAndValue objectAtIndex:0];
                    NSString *value = [arrKeyAndValue objectAtIndex:1];
                    [view updateCSS:key withValue:value];
                }
                break;
            }
         
            //部分View 的native属性,比如字体粗细,比如背景色,需要进行设置
            case DOM_UPDATE_ATTR: {
                FBView *view = [self findViewByNode:op->node];

                NSString *style = [NSString stringWithUTF8String:(char*)op->param];
                NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
                if (arrKeyAndValue.count == 2) {
                    NSString *key = [arrKeyAndValue objectAtIndex:0];
                    NSString *value = [arrKeyAndValue objectAtIndex:1];
                    [view updateAttr:key withValue:value];
                }
                break;
            }
                
            //部分 View 可以接受交互事件,比如点击事件 onClick,需要把原生的点击事件,与点击事件响应的js 绑定起来
            //因此这个 action 用于绑定 js 函数给 native 的点击事件 
            case DOM_UPDATE_EVENT: {
                FBView *view = [self findViewByNode:op->node];
                
                NSString *style = [NSString stringWithUTF8String:(char*)op->param];
                NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
                if (arrKeyAndValue.count == 2) {
                    NSString *key = [arrKeyAndValue objectAtIndex:0];
                    NSString *value = [arrKeyAndValue objectAtIndex:1];
                    [view updateEvent:key withValue:value];
                }

                break;
            }
                
            //部分 View 可以接受 JS 代码控制,提供给 JS Native 方法可以调用
            //因此这个 action 用于绑定 native 方法给 js
            case DOM_UPDATE_FUNC: {
                FBView *view = [self findViewByNode:op->node];
                
                [view updateFunc:[NSString stringWithUTF8String:(char*)op->param] withValue:PARAM_DELIMITER_OC];
            }

            default:
                break;
        }
        
        // ... 省略部分代码 
    }
    
    // ... 省略部分代码 
}

当 updateLayout 执行完毕后,对 FBDocument 调用他的 getView 方法,就能直接获取 RootView,也就是整个鸟巢界面了。

原生控件与JS代码之间交互

  • DOM_UPDATE_EVENT
  • DOM_UPDATE_FUNC

其实就是上面介绍的2个核心的 actionSeq 指令,而这两个指令会分别调用 FBView 的下面两个方法

- (void)updateEvent:(NSString*)key withValue:(NSString*)value;
- (void)updateFunc:(NSString*)key withValue:(NSString*)value;

FBView 会有2个空方法实现,不同的原生 View 组件会根据自己的组件设计去重写这两个方法,从而识别正确下发的 key 与 value

  • DOM_UPDATE_EVENT

拿 FBImage 举例,FBImg 就是一个可以接受点击,触发点击事件的原生组件,所以在 FBImg 的 updateEvent 代码中,native 代码会识别 value 为“onClick” 的时候,知道要给 FBImg 构建一个 tap 手势识别器,当发生点击的时候,会调用 onClicked:方法,触发 fb_platform_onclick 来调用 js

- (void)updateEvent:(NSString *)key withValue:(NSString *)value
{
    if ([value isEqualToString:@"onclick"]) {
        [self.view setUserInteractionEnabled:YES];
        if (_tapRecognizer == nil) {
            _tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onClicked:)];
            [self.view addGestureRecognizer:_tapRecognizer];
            const fb_node_t *fbNode = [self getFbNode];
            if (fbNode != nil && fbNode->_id != NULL) {
                NSString *seed = [[NSString alloc] initWithUTF8String:[self getFbNode]->_id];
                SEL selector = @selector(setActionName:);
                if ([_tapRecognizer respondsToSelector:selector]) {
                    [_tapRecognizer performSelector:selector withObject:seed];
                }
            }
        }
    }
}

- (void)onClicked:(id)sender
{
    if (_imageView)
        self.doc.focusView = _imageView;

    fb_platform_onclick([self getFbNode]);
}

fb_platform_onclick 这个方法核心是用 js 的 duktap 解释器,调用 fb_script_execute 去执行 js 代码,类似于 WebView 的 evaluateScript

  • DOM_UPDATE_FUNC

拿 FBInput 举例,FBInput 就是一个可以被 js 主动调用 focus/unfocus 2个方法的控件,native 应该把聚焦/失焦,以 focus/blur 2个命名,提供给 JS

- (void)updateFunc:(NSString*)key withValue:(NSString*)value {

    if ([key isEqualToString:@"focus"]) {

        [_textfield becomeFirstResponder];
    }
    else if ([key isEqualToString:@"blur"]) {

        [_textfield resignFirstResponder];
    }
    else {
        [super updateFunc:key withValue:value];
    }
}

在这种 JS 主动调用 native 的情况下,真正的链接 native 与 js duktape 引擎的是两个 fb_script.c & fb_script_ductape.c 的类,这两个类的主要工作是进行 js 的内存数据序列化反序列化成 native 的数据,对识别出来的数据,从而调用对应的native 方法,传递数据。

略微展开一下就是:

  • fb_script_duktape 中的 fb_script_register_doc 方法,把所有的 native可提供的方法表的名字字符绑定到了js环境,当任意 js 代码调用了这些绑定名,会触发 fb_script_doc_func 方法
  • fb_script_doc_func 方法会触发 fb_script_bind_node 方法
  • fb_script_bind_node 方法会触发 fb_script_node_func 方法
  • fb_script_node_func 中会识别方法名,如果是 ‘blur’ / “focus” 会触发 fb_core_add_action
  • fb_core_add_action 的作用就是创建 DOM_UPDATE_FUNC 事件,并且放入执行队列

这块还是比较复杂,涵盖整个 js 与 oc 2个上下文交互,可以的话推荐阅读这两个类的源码

动态化方案对比思考

账单详情页鸟巢尝试的阻碍

当初账单详情页因为追求动态化,曾经切换过鸟巢,后来因为种种原因又切换为了现在的H5,异草留下了一篇当时的文档 https://yuque.antfin-inc.com/aone488674/qzz32g/kav7rq

  • 鸟巢虽然是采用 H5 语言来开发,但是仅支持部分 HTML、CSS,实现方式会受限;
  • 没有框架支持,只能使用原生的 JS,比较 ReactNative 和 Weex 有一定差距,没有样式库;
  • 调用原生组件不够丰富,业务开发需要依赖鸟巢资源投入,不过后期有所改善;
  • 调试不方便,对开发来说比较影响效率,部分 Bug 需要调鸟巢源码进行调试;
  • 动态更新前期存在很多问题,处于不可用状态,离线包失败没有fallback;

简单的说:

产品功能上:由于只是 H5 WebKit 的子集,一些HTML CSS 方式受限,效果受限

开发效率上:原生组件,样式库,以及JS脚手架等框架支持少,调试 工具 不健全,开发效率不高

部署与稳定性上:有灰度与更新机制,但相关监控机制不足,回滚机制不足

综合一句话就是:

鸟巢的整个机制与扩展性上还好,缺乏的是相关技术生态的框架建设

思考技术发展的趋势脉络

原生 鸟巢 ReactNative Weex WebView 小程序 WebView H5
渲染能力 任何效果都能实现 只有 FlexBox 布局支持的效果 只支持 FlexBox 算法的效果 只支持 Web 标准的各算法效果
暂不算 Canvas 能力
只支持 Web 标准的各算法效果
暂不算 Canvas 能力
布局性能 frame布局最佳
autolayout布局视算法而定
FlexBox 性能公认不错 FlexBox 性能公认不错 支持各种布局算法混合使用,在布局运算上,css写的越乱性能越差,优先使用 FlexBox 也会有不错的性能 支持各种布局算法混合使用,在布局运算上,css写的越乱性能越差,优先使用 FlexBox 也会有不错的性能
渲染性能 原生渲染,可直接底层图形渲染API,也可原生UI组件渲染 原生UI组件渲染 原生UI组件渲染 WebView 渲染 + 原生组件UI贴片
混合式渲染
WebView 渲染
底层直接调用平台的系统GUI API
运算性能 性能最佳 js 虚拟机运行执行,性能不如原生
有 js 与原生上下文切换开销
js 虚拟机执行,性能不如原生
js 运行环境有庞大 React JS 框架
有 js 与原生上下文切换开销
js 虚拟机执行,性能不如原生
js 运行环境有庞大 React JS 框架
有 js 与原生上下文切换开销
js 虚拟机执行,性能不如原生
是否有庞大 JS 框架取决于业务
是否有JS与原生上下文切换取决于是否 Hybrid
更新包内容 纯数据更新,只能靠网络接口刷新数据展现 html css js 合成一体的 JSON 模板更新 html css js 图片资源打包成 jsbundle 整体包更新 html css js 图片 打包成小程序离线包整体更新 html css js 图片各资源分散独立,各自更新各自的
部署更新机制 无法热更新 整个包进行版本更新控制 整个包进行版本更新控制
如果包过大可以辅助拆包分包更新策略
整个包进行版本更新控制
包过大有拆包分包更新策略
每个文件独立更新控制,受 WebView 缓存协议管控更新
native发版依赖 每次都要发版 只有鸟巢组件与框架不足需要扩充才要发版(但是鸟巢组件相比RN不够全面) 只有 RN 的 Native 组件与框架不足需要扩充才要发版(相对来说 Native组件比较完善,发版频次少于鸟巢) 只有 RN 的 Native 组件与框架不足需要扩充才要发版(相对来说 小程序有很多Web组件,Native组件不多,发版机会也少) 完全不需要发版
除非 Hybrid WebView 容器框架迭代开发

好多人都会画这种对比表格,来表明哪种技术方案优劣,但仔细看这个表格,你会发现在 Web 技术动态化这个方向上,所有的技术方案不是割裂的,而是一脉相承的。

  • Native 是动态化之路的起点,动态能力几乎为0
  • WebView 是动态化之路的终点,动态能力堪称完美(毕竟是W3C标准组织沉淀了一二十年)

但因为移动端的机器性能不及PC电脑,所以在PC端已经极致成熟的Web庞大功能,在移动端施展不开。于是就在移动端,重新根据移动端设备的性能,沿着 Web 标准发展路线,优先选取最需要的动态能力,融入移动端

  • Native 是动态化之路的起点,动态能力几乎为0
    • –> 从 Native 朝着 WebView 去发展 去扩充
      • 只选取性能最高的 FlexBox 布局能力
      • 极简 JS 引擎框架
      • 整包整体下载后离线运行
    • – > 形成了鸟巢
      • 扩充了更完善的Native UI组件
      • 扩充了JS层高效的 React 开发框架
      • jsbundle的整包分包拆包更新机制
      • 更加合理的整套框架设计
    • – > 形成了 RN
  • WebView 是动态化之路的终点,动态能力堪称完美(毕竟是W3C标准组织沉淀了一二十年)
    • – > 从 WebView 朝着 Native 去发展 去删减不必要的功能开销
      • 删减掉 WebView 的每个文件细粒度实时更新控制与缓存控制,改为离线包本地加载
      • 在CSS这块尽量推荐使用 FlexBox 的布局设计
      • 在JS这块强制框架引入 MVVM 框架与 dom diff 算法,禁止任意操作dom导致性能下降
      • 多 WebView 共用 service worker 统一逻辑 js 上下文异步运行js
      • 在 WebView 的基础上 融入 原生UI组件,Hybrid 渲染,提高一些特殊组件的性能效果
    • – > 形成了 WebView 小程序

一个共识是现阶段原生动态化大家相对都认可的:那就是看齐Web的几个标准。因为Web的技术体系在UI的描述能力以及灵活度上确实设计得很优秀的,而且相关的开发人员也好招。所以,如果说混合开发指的是Native里运行一个Web标准,来看齐Runtime来写GUI,并桥接一部分Native能力给这个Runtime来调用的话,那么它应该是一个永恒的潮流。

引用自: http://awhisper.github.io/2016/06/16/前端10年读后感/


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

运营之光

运营之光

黄有璨 / 电子工业出版社 / 2016-9-1 / 59.00元

在互联网行业内,“运营”这个职能发展到一定阶段后,往往更需要有成熟的知识体系和工作方法来给予行业从业者们以指引。 《运营之光:我的互联网运营方法论与自白》尤其难得之处在于:它既对“什么是运营”这样的概念认知类问题进行了解读,又带有大量实际的工作技巧、工作思维和工作方法,还包含了很多对于运营的思考、宏观分析和建议,可谓内容完整而全面,同时书中加入了作者亲历的大量真实案例,让全书读起来深入浅出、......一起来看看 《运营之光》 这本书的介绍吧!

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具