iOS-中的Flex布局-FlexLib的使用

一. 概述

进行iOS项目开发时,常用的布局方式有两种:Frame布局和AutoLayoutFrame布局没啥可说的,直接设置控件的横纵坐标,并指定宽高。AutoLayout是通过设置控件相对位置的约束进行布局。AutoLayout的本意是好的,但是由于他的语法实在不怎么友好,导致我们在实际项目开发中用的并不多,只能靠Masonry这样的第三方库来使用它。Masonry布局虽然容易理解,但是它的代码量太大,每一个控件都要对其进行block设置。
在前端开发中,Flex布局使用尤为普遍,那么iOS端有没有类似前端Flex布局的方案呢?答案是肯定的,除了在 Weex 以及 React Native 两个著名的跨平台项目里有用到 Flex布局外,AsyncDisplayKit 也同样引入了 Flex布局,它们都是基于 FacebookYoga 的二次开发。而今天我们要介绍的 FlexLib 同样如此。

二. Flex布局与相关属性介绍

先来了解一下Flex布局:
Flex布局又叫弹性布局,是一种为一维布局而设计的布局方法。一维的意思是你希望内容是按行或者列来布局。你可以使用display:flex来将元素变为弹性布局。我们直接看例子:
实现7个item横向排列
图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!DOCTYPE html>
<html>
<head>
<title>flex布局2</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-end;
background-color: wheat;
flex-wrap: wrap;
}
.item {
width: 100px;
height: 100px;
border: 1px solid royalblue;
text-align: center;
line-height: 100px;
margin: 10px 10px;
}
.item6 {
flex: 1;
}
.item7 {
flex: 2;
}
</style>
</head>

<body>
<div class="container">
<div class="item0 item">item0</div>
<div class="item1 item">item1</div>
<div class="item2 item">item2</div>
<div class="item3 item">item3</div>
<div class="item4 item">item4</div>
<div class="item5 item">item5</div>
<div class="item6 item">item6</div>
<div class="item7 item">item7</div>
</div>
</body>
</html>

上面是前端代码,看不懂没关系,这里重点是借这个例子来熟悉一下Flex布局的一些常用属性。Flex属性分类两类:一类是作用在容器上的,另一类是作用在子Item上的。

  1. 作用在容器上的属性
  • display:flex: 设置container容器为弹性布局

  • flex-direction:决定主轴的方向,项目横向或是纵向排列
    取值:row | row-reverse | column | column-reverse;
    row(默认值):主轴为水平方向,起点在左端。
    row-reverse:主轴为水平方向,起点在右端。
    column:主轴为垂直方向,起点在上沿。
    column-reverse:主轴为垂直方向,起点在下沿。

  • justify-content: 定义Item在主轴上如何对齐。
    取值:flex-start | flex-end | center | space-between | space-around;
    flex-start(默认值):左对齐
    flex-end:右对齐
    center: 居中
    space-between:两端对齐,项目之间的间隔都相等。
    space-around:每个项目两侧的间隔相等。所以,项目之间的间隔比项目与边框的间隔大一倍。

  • align-items:定义Item在交叉轴上如何对齐。
    取值:align-items: flex-start | flex-end | center;
    flex-start:交叉轴的起点对齐。
    flex-end:交叉轴的终点对齐。
    center:交叉轴的中间点对齐。

  • flex-wrap:一条轴线上放不下,决定其是否换行
    取值: nowrap(不换行) | wrap(换行)

  • align-content:也是控制Flex Item 在交叉轴上的对齐方式,只不过是以一整行作为最小单位。注意,如果Flex Item只有一行,该属性不起作用的。调整 flex-wrap 为 Wrap,效果才显示出来。
    取值:flex-start | flex-end | center | space-between | space-around;

  1. 作用在Item上的属性
  • align-self:可以让单个 Flex Item 与其它 Flex Item 有不一样的对齐方式,覆盖 align-items属性。默认值为auto,表示继承Flex容器的align-items属性。

  • flex 属性:flex属性是flex-grow, flex-shrinkflex-basis的简写,默认值为0 1 auto
    flex-grow: 属性定义项目的放大比例,默认为0,即如果存在剩余空间也不放大。 取值越大,占用剩余空间越大。
    flex-shrink: 属性定义了项目的缩小比例,默认为1,即如果空间不足,该项目将缩小。
    flex-basis:属性指定了flex元素在主轴方向上的初始大小

这里设置item6item7 的flex为1和2,表示当前轴剩余的空间item6占1/3,item7占2/3.
更多的关于flex的属性可以查看这里:Flex布局

三. FlexLib的使用

1.使用方式

CocoaPods引入FlexLib:

1
pod 'FlexLib'

使用:

  • 创建视图控制器继承FlexBaseVC,或者创建视图继承FlexCustomBaseView
  • 创建与视图控制器或视图同名的xml文件
  • 在xml文件中添加控件进行布局
    例子:

pic

TestLoginVC.h

1
2
@interface TestLoginVC : FlexBaseVC
@end

TestLoginVC.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#import "TestLoginVC.h"

@interface TestLoginVC ()
{
UIScrollView* scroll;//自动绑定name属性的值
UIView* close;
}

@end

@implementation TestLoginVC

- (void)viewDidLoad {
[super viewDidLoad];

self.navigationItem.title = @"Touch Demo";
}

- (void)tapTouchAction
{
UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@""
message:@"You pressed"
delegate:nil
cancelButtonTitle:@"Cancel"
otherButtonTitles:@"OK",nil];

[alert show];
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

TestLoginVC.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8"?>
<UIView
layout="
flex:1,
alignItems:center"
attr="bgColor:white">
<FlexScrollView name="scroll" layout="padding:50,flex:1,width:100%,alignItems:center" attr="bgColor:white,vertScroll:true,">
<FlexTouchView onPress="tapTouchAction" attr="underlayColor:blue">
<UIImageView layout="width:100,height:100" attr="borderRadius:50,clipsToBounds:true,source:qrcode.png">
<FlexTouchMaskView attr="bgColor:red,alpha:0.7"/>
</UIImageView>
</FlexTouchView>
<UIView layout="height:20"/>
<FlexTouchView onPress="tapTouchAction" layout="height:100,width:100%,alignItems:center,justifyContent:center" attr="bgColor:#e5e5e5,borderRadius:8,underlayColor:darkGray">
<UILabel layout="width:100%" attr="fontSize:16,textAlign:center,color:red,text:Please touch me and move\,\r\n这是测试touch功能的控件,linesNum:0"/>
</FlexTouchView>
<UIView layout="height:20"/>
<UILabel layout="width:100%" attr="fontSize:20,linesNum:0,color:#333333,text:You can press the buttons above to see the effect. We use FlexTouchView to provide more powerful function and better flexibility than button."/>
</FlexScrollView>
</UIView>

说明:

  • 默认视图从上到下进行排列,在视图标签内添加其他视图,相当于为视图添加子视图

  • FlexLib支持两种类型的属性:布局属性(Layout)和视图属性(attr),布局属性与yoga所支持的属性一致,视图属性除了文档中所列的属性以外,还可以使用FLEXSET宏对现有属性进行扩展。你可以在这里查看它所支持的 layout attributesview attributes

  • FlexScrollView(UIScrollView的子类)可以自动管理滚动范围,FlexTouchView类似于UIButton,内置onPress属性,可以设置其触发方法。

  • 可以设置控件的name属性,name属性的值会自动绑定代码中具有相同名称的成员变量,你可以直接在代码中使用它。

  • 你可以预定义一些属性值,将其放入全局xml文件中,在AppDelegate进行初始化

全局xml文件 system.style 的格式类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<styles>
<style name="buttonAttrStyle">
<attr name="bgColor">black</attr>
<attr name="color">white</attr>
<attr name="borderRadius">4</attr>
<attr name="underlayColor">darkGray</attr>
<attr name="shadowColor">red</attr>
<attr name="shadowOffset">5/5</attr>
<attr name="shadowOpacity">0.2</attr>
<attr name="shadowRadius">3</attr>
</style >
<style name="buttonLayoutStyle">
<attr name="height">44</attr>
<attr name="width">150</attr>
<attr name="alignItems">center</attr>
<attr name="justifyContent">center</attr>
<attr name="margin">20</attr>
</style>
</styles>

导入系统样式文件

1
2
NSString *path = [[NSBundle mainBundle]pathForResource:@"system" ofType:@"style"];
[[FlexStyleMgr instance] loadClassStyle:path];

使用:

1
<UILabel attr="@:system/buttonAttrStyle" layout="@:system/buttonLayoutStyle" />
  • FlexLib支持运行时更新界面,具体的配置方式那你可以看这里

  • FlexLib支持Cell高度自动计算,并且适配iPhone X等机型,实际开发中自定义UITableViewCell需要继承FlexBaseTableCell,自定义UICollectionViewCell需要继承FlexCollectionCell

2.FlexXmlBaseView、FlexFrameView、FlexCustomBaseView 的使用

在使用xml进行布局时,不可避免的要用到自定义视图,如果自定义视图也想要用xml进行布局,此时需要继承FlexXmlBaseView FlexFrameViewFlexCustomBaseView这三种视图中的一种。

(1)自定义视图使用xml布局
  • 继承FlexXmlBaseView的视图:能在xml文件中使用,可以通过initWithRootView方式创建,但不能通过initWithFrame创建,也不能能直接设置frame,优点是更加轻量级,不会增加额外的视图层级.

  • 继承FlexFrameView的视图:可以直接设置frame,但是不能用在xml文件中。例如

    1
    2
    3
    FlexFrameView *frameView = [[FlexFrameView alloc] initWithFlex:@"CustomFrameView" Frame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, 0) Owner:self];
    frameView.flexibleHeight = YES;//设置高度自适应
    [_scrollView addSubview:frameView];

    CustomFrameView.xml是这个视图对应的xml布局文件

  • 继承FlexCustomBaseView的视图:既可以用在xml中,也可以像传统的UIView派生类那样使用initWithFrame创建,缺点是会额外的增加多余的视图层级.

注意:继承FlexCustomBaseViewFlexFrameView的视图必须要设置以下两个属性

1
2
3
//宽和高是否可变?缺省值均为NO
@property(nonatomic,assign) BOOL flexibleWidth;
@property(nonatomic,assign) BOOL flexibleHeight;
(2)自定义视图不使用xml布局,但是也想用在xml文件中

此时我们的自定义视图需要满足以下两个条件就可以用于xml文件中:

  • 所有的初始化必须在init方法中完成。任何其他的init函数,比如initWith…不会被调用。
  • 你可以用FLEXSET宏扩展视图属性,然后你可以在xml文件中设置属性。

直接在父视图中使用

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<UIView layout="flex:1,alignItems:center" attr="bgColor:white">
<!-- 无xml对应的,自定义View 这么使用 -->
<ComplateCustomView layout="width:100%,height:80"/>
</UIView>

3.其他设置

  • 缩放因子
    在进行页面布局时,如果想让不同尺寸的屏幕共用一套设计,随着屏幕尺寸的变化,页面控件的大小随之缩放,这时候会用到缩放因子。设置缩放因子:

    1
    2
    3
    // 设置缩放因子,使用方法 在xml 布局中 *16 ==> 16*factor + 1
    float factor = [UIScreen mainScreen].bounds.size.width/375;
    FlexSetScale(factor, 1);

    在布局xml中使用,数字前面加上*

    1
    <UILabel attr="fontSize: *16"/>

    这表示:Label的fontSize为16*factor + 1

  • 在属性中使用表达式
    在布局属性和视图属性中添加表达式,属性名前要加$,ScreenWidth是系统预定义的宏,你也可以用自定义的宏。目前支持的运算符+, -, *, /, (, ),

1
2
3
<UIView layout="$width:ScreenWidth*0.6,height:50,alignItems:center,justifyContent:center" attr="bgColor:#e5e5e5,borderRadius:6">
<UILabel attr="@:system/buttonText,text:表达式计算,屏幕宽度的60%"/>
</UIView>
  • FlexTextView
    它是另外一个系统提供的类,能够自动根据输入的文字调整其高度,且保证其高度不会超出最小(minHeight)和最大高度(maxHeight)。
    1
    <FlexTextView layout="flex:1,minHeight:40,maxHeight:95" attr="borderColor:red,borderWidth:1,borderRadius:4,fontSize:18,color:darkGray,text:这是一个能自动根据字数调整高度的文本输入框,placeholder:我是占位,placeholderColor:red"/>

四. 总结

FlexLib 可能对有前端开发经验的同学比较友好,对原生开发同学而言,学习一下他的语法也没坏处。在日常开发中,我们可以有选择的使用这个布局,熟悉了这个布局后,会极大的提升我们页面布局效率,从而让我们将精力集中到更加核心的功能上。

参考:

https://github.com/zhenglibao/FlexLib

WebViewJavascriptBridge源码解析

一. 概述

做客户端开发免不了要与WebView打交道,特别是对于Hybrid App,在H5所占比重越来越大的背景下,一套好的WebView 与原生交互的API显得尤为重要,当然目前两端都有比较成熟的三方库进行支持。比如Android端的JsBridge,iOS端的WebViewJavascriptBridge,但是对于其内部原理笔者一直一知半解,导致有时面对问题无从下手,最后决心分析WebViewJavascriptBridge的内部实现原理,一是提升自己的源码阅读水平,其次也希望对以后的工作有所帮助。

二. 基本原理

pic
下载WebViewJavascriptBridge的源码后可以看到其文件并不多,分别对几个文件做简单的介绍,后面详细分析其源码

  • WebViewJavascriptBridge_JS: JS桥接文件,通过它实现JS环境的初始化,里面就一个C函数,返回的是JS方法。原生调用的JS方法与对应的方法回调都需要先在这里面进行注册。
  • WKWebViewJavascriptBridgeWebViewJavascriptBridge: WKWebViewUIWebView对应的桥接文件。JS调用的原生方法与对应的方法回调都需要先在这里面进行注册。
  • WebViewJavascriptBridgeBase: 桥接基础文件。通过他实现对原生环境的初始化,以及对方法存储容器的初始化,当然还有对WebViewJavascriptBridge_JS里面JS方法的调用。

三. 源码解析

大体了解了上面几个类的作用,我们通过源码来分析其内部的实现逻辑。我们就以WebViewJavascriptBridgeDemo为例。

1. JS调用OC方法

(1) OC环境初始化与方法注册

如何实现JS调用OC方法呢,首先要对当前OC环境进行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ExampleWKWebViewController
_bridge = [WebViewJavascriptBridge bridgeForWebView:webView];
[_bridge setWebViewDelegate:self];

[_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"testObjcCallback called: %@", data);
responseCallback(@"Response from testObjcCallback");
}];

....

// WKWebViewJavascriptBridge
+ (instancetype)bridgeForWebView:(WKWebView*)webView {
WKWebViewJavascriptBridge* bridge = [[self alloc] init];
[bridge _setupInstance:webView];
[bridge reset];
return bridge;
}

....
// WKWebViewJavascriptBridge
- (void) _setupInstance:(WKWebView*)webView {
_webView = webView;
_webView.navigationDelegate = self;
_base = [[WebViewJavascriptBridgeBase alloc] init];
_base.delegate = self;
}
  • [WebViewJavascriptBridge bridgeForWebView:webView];: 看这个方法的调用栈,可以清晰的看到其作用是初始化 WKWebViewJavascriptBridge,进而实例化其对应的WebViewJavascriptBridgeBase,还有绑定各自的代理,最终实现初始化OC调用环境的目的。

  • - (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler; : 如果要实现JS调用原生方法的目的,那么必须对原生方法进行注册,这个就是对应的注册方法。我们来看他内部做了什么:

    1
    2
    3
    - (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler {
    _base.messageHandlers[handlerName] = [handler copy];
    }

    很简单,只不过把当前的Block保存进了messageHandlers这个字典中,以便等JS端调用时,通过方法名称来找到其对应的实现。

(2) JS环境初始化与方法触发

OC环境初始化与方法注册完成后,我们来下JS环境的初始化
Demo中通过- (void)loadExamplePage:(WKWebView*)webView 方法加载网页到当前的webView,来看下ExampleApp.html中的核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'https://__bridge_loaded__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}

setupWebViewJavascriptBridge(function(bridge) {
var uniqueId = 1
function log(message, data) {
var log = document.getElementById('log')
var el = document.createElement('div')
el.className = 'logLine'
el.innerHTML = uniqueId++ + '. ' + message + ':<br/>' + JSON.stringify(data)
if (log.children.length) { log.insertBefore(el, log.children[0]) }
else { log.appendChild(el) }
}

bridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
log('ObjC called testJavascriptHandler with', data)
var responseData = { 'Javascript Says':'Right back atcha!' }
log('JS responding with', responseData)
responseCallback(responseData)
})

document.body.appendChild(document.createElement('br'))

var callbackButton = document.getElementById('buttons').appendChild(document.createElement('button'))
callbackButton.innerHTML = 'Fire testObjcCallback'
callbackButton.onclick = function(e) {
e.preventDefault()
log('JS calling handler "testObjcCallback"')
bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
log('JS got response', response)
})
}
})
  • setupWebViewJavascriptBridge(callback)是核心方法,webView加载html后会首先调用这个方法。这个方法需要一个参数callback,也是一个函数。我们来看这个方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function setupWebViewJavascriptBridge(callback) {
    if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
    if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
    window.WVJBCallbacks = [callback];
    var WVJBIframe = document.createElement('iframe');
    WVJBIframe.style.display = 'none';
    WVJBIframe.src = 'https://__bridge_loaded__';
    document.documentElement.appendChild(WVJBIframe);
    setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
    }
    第一次加载网页时 window.WebViewJavascriptBridgewindow.WVJBCallbacks都是false,把window.WVJBCallbacks赋值为包含callback的数组,此时callback为一个函数,就是后面的function(bridge) ....,接下来创建WVJBIframe,你可以把它理解为一个空白页面,创建它的目的是设置src = 'https://__bridge_loaded__';,

注意这个src属性很关键,当我们设置一个网页的src属性时,这个链接会被我们OC端的webView所捕获,从而调用webView的代理方法- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler,

后面两句代码的的意思是加载当前空白页,以便触发OC的代理方法,然后立马移除。

  • 接下来我们去WKWebViewJavascriptBridge中看 - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler 这个代理方法拦截到请求后做了什么。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    NSURL *url = navigationAction.request.URL;
    __strong typeof(_webViewDelegate) strongDelegate = _webViewDelegate;

    if ([_base isWebViewJavascriptBridgeURL:url]) {
    if ([_base isBridgeLoadedURL:url]) {
    [_base injectJavascriptFile];
    } else if ([_base isQueueMessageURL:url]) {
    [self WKFlushMessageQueue];
    } else {
    [_base logUnkownMessage:url];
    }
    decisionHandler(WKNavigationActionPolicyCancel);
    return;
    }

    首先判断当前的URL是否是__wvjb_queue_message__或者__bridge_loaded__,刚才触发的URL是 __bridge_loaded__
    会调用WebViewJavascriptBridgeBase- (void)injectJavascriptFile方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    - (void)injectJavascriptFile {
    // 获取JS字符串
    NSString *js = WebViewJavascriptBridge_js();
    [self _evaluateJavascript:js];
    if (self.startupMessageQueue) {
    NSArray* queue = self.startupMessageQueue;
    self.startupMessageQueue = nil;
    for (id queuedMessage in queue) {
    [self _dispatchMessage:queuedMessage];
    }
    }
    }

    ....

    - (void) _evaluateJavascript:(NSString *)javascriptCommand {
    [self.delegate _evaluateJavascript:javascriptCommand];
    }

    ....

    - (NSString*) _evaluateJavascript:(NSString*)javascriptCommand {
    [_webView evaluateJavaScript:javascriptCommand completionHandler:nil];
    return NULL;
    }

    通过以上方法调用可以看到,最后是把WebViewJavascriptBridge_js(); JS方法字符串,通过方法 [_webView evaluateJavaScript:javascriptCommand completionHandler:nil] 注入到了webView中并且执行。从而达到初始化javascript环境的brige的作用。

  • WebViewJavascriptBridge_js()方法解析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    window.WebViewJavascriptBridge = {
    registerHandler: registerHandler,
    callHandler: callHandler,
    disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
    _fetchQueue: _fetchQueue,
    _handleMessageFromObjC: _handleMessageFromObjC
    };

    var messagingIframe;
    // 要发送给原生的消息列表
    var sendMessageQueue = [];
    // 存储注册在bridge的JS方法
    var messageHandlers = {};
    // 要跳转的URL
    var CUSTOM_PROTOCOL_SCHEME = 'https';
    var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';
    //JS方法回调
    var responseCallbacks = {};
    var uniqueId = 1;
    var dispatchMessagesWithTimeoutSafety = true;
    // OC调用的JS方法需要用它来进行注册
    function registerHandler(handlerName, handler) {
    messageHandlers[handlerName] = handler;
    }
    //JS调用OC的方法入口
    function callHandler(handlerName, data, responseCallback) {
    if (arguments.length == 2 && typeof data == 'function') {
    responseCallback = data;
    data = null;
    }
    _doSend({ handlerName:handlerName, data:data }, responseCallback);
    }
    function disableJavscriptAlertBoxSafetyTimeout() {
    dispatchMessagesWithTimeoutSafety = false;
    }
    // 要发送消息给原生了
    function _doSend(message, responseCallback) {
    if (responseCallback) {
    var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
    responseCallbacks[callbackId] = responseCallback;
    message['callbackId'] = callbackId;
    }
    sendMessageQueue.push(message);
    //触发webView 代理,解析JS 的message
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    }
    // 把消息转成json字符串
    function _fetchQueue() {
    var messageQueueString = JSON.stringify(sendMessageQueue);
    sendMessageQueue = [];
    return messageQueueString;
    }

    function _dispatchMessageFromObjC(messageJSON) {
    ....
    }
    // 原生会调用他,JS用它来达到消息分发
    function _handleMessageFromObjC(messageJSON) {
    _dispatchMessageFromObjC(messageJSON);
    }

    messagingIframe = document.createElement('iframe');
    messagingIframe.style.display = 'none';
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    document.documentElement.appendChild(messagingIframe);

    setTimeout(_callWVJBCallbacks, 0);
    function _callWVJBCallbacks() {
    var callbacks = window.WVJBCallbacks;
    delete window.WVJBCallbacks;
    for (var i=0; i<callbacks.length; i++) {
    callbacks[i](WebViewJavascriptBridge);
    }
    }

    我截选了一些关键代码,首先整个WebViewJavascriptBridge_js是一个JS方法的执行,首先创建了JS端的WebViewJavascriptBridge并赋值给了window,我们来看这个对象的构成:

    1
    2
    3
    4
    5
    6
    7
    window.WebViewJavascriptBridge = {
    registerHandler: registerHandler,
    callHandler: callHandler,
    disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
    _fetchQueue: _fetchQueue,
    _handleMessageFromObjC: _handleMessageFromObjC
    };
  • registerHandler:直接对应下面的registerHandler(handlerName, handler) 方法,通过它我们把能被OC调用的JS方法进行注册,看它的实现也是比较简单的

1
2
3
function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}

把JS的方法实现以方法名handleName保存在messageHandlers中。

  • callHandler: 对应下面callHandler(handlerName, data, responseCallback)方法,通过它我们可以直接发起对OC方法的调用,具体调用逻辑我们在下面进行分析。
  • disableJavscriptAlertBoxSafetyTimeout:回调是否超时开关,默认为false
  • _fetchQueue: 把javascript环境的方法序列化成JSON字符串,并返回给OC端
  • _handleMessageFromObjC:处理OC发给javascript环境的方法,_dispatchMessageFromObjC(messageJSON)这个方法的参数就是OC调用JS的message信息,这个方法对messageJSON进行解析处理,进而调用相应的JS方法。
1
2
3
4
messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
document.documentElement.appendChild(messagingIframe);

这里的src就是https://__wvjb_queue_message__,这段代码的意思是把javascript要发送给OC的消息立即发送出去。

1
2
3
4
5
6
7
8
setTimeout(_callWVJBCallbacks, 0);
function _callWVJBCallbacks() {
var callbacks = window.WVJBCallbacks;
delete window.WVJBCallbacks;
for (var i=0; i<callbacks.length; i++) {
callbacks[i](WebViewJavascriptBridge);
}
}

WebViewJavascriptBridge_js的最后是上面的代码,它会调用ExampleApp.html中的callBack方法,也就是它

1
2
3
setupWebViewJavascriptBridge(function(bridge) {
....
})

继而完成对这个JS环境的初始化与ExampleApp.html的加载。

(3) JS调用OC方法流程
  • 点击JS按钮触发下面的方法
    1
    2
    3
    bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
    log('JS got response', response)
    })
    传递方法名testObjcCallback,消息参数{'foo': 'bar'},以及OC回调JS的方法function(response) {log('JS got response', response)}),
  • 调用WebViewJavascriptBridge_js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    function callHandler(handlerName, data, responseCallback) {
    if (arguments.length == 2 && typeof data == 'function') {
    responseCallback = data;
    data = null;
    }
    _doSend({ handlerName:handlerName, data:data }, responseCallback);
    }

    ....

    function _doSend(message, responseCallback) {
    if (responseCallback) {
    var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
    responseCallbacks[callbackId] = responseCallback;
    message['callbackId'] = callbackId;
    }
    sendMessageQueue.push(message);
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
    }

    可以看到核心方法是_doSend(),入参message是调用OC的方法名与参数,responseCallback是OC回调JS的方法,接下来将这个回调方法保存在responseCallbacks中,key值是callbackId,消息对象message也添加一个callbackId,最后设置messagingIframe的src属性,从而被ebView的代理方法- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler拦截。
  • 在上面的代理方法中,拦截到的URL为__wvjb_queue_message__,所以调用方法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    - (void)WKFlushMessageQueue {
    [_webView evaluateJavaScript:[_base webViewJavascriptFetchQueyCommand] completionHandler:^(NSString* result, NSError* error) {
    if (error != nil) {
    NSLog(@"WebViewJavascriptBridge: WARNING: Error when trying to fetch data from WKWebView: %@", error);
    }
    [_base flushMessageQueue:result];
    }];
    }

    ....

    - (NSString *)webViewJavascriptFetchQueyCommand {
    return @"WebViewJavascriptBridge._fetchQueue();";
    }
    WebView触发JS的WebViewJavascriptBridge._fetchQueue(),
    1
    2
    3
    4
    5
    function _fetchQueue() {
    var messageQueueString = JSON.stringify(sendMessageQueue);
    sendMessageQueue = [];
    return messageQueueString;
    }
    这个方法里面会将sendMessageQueue换成json字符串,然后返回给OC环境,触发[_base flushMessageQueue:result];
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    - (void)flushMessageQueue:(NSString *)messageQueueString{
    if (messageQueueString == nil || messageQueueString.length == 0) {
    NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
    return;
    }
    // JS传递过来的json字符串,我们进行反序列化 得到message数组
    NSLog(@"messageQueueString===%@",messageQueueString);
    id messages = [self _deserializeMessageJSON:messageQueueString];

    for (WVJBMessage* message in messages) {
    if (![message isKindOfClass:[WVJBMessage class]]) {
    NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
    continue;
    }
    [self _log:@"RCVD" json:message];

    NSString* responseId = message[@"responseId"];
    if (responseId) {
    WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
    responseCallback(message[@"responseData"]);
    [self.responseCallbacks removeObjectForKey:responseId];
    } else {
    WVJBResponseCallback responseCallback = NULL;
    NSString* callbackId = message[@"callbackId"];
    if (callbackId) { // 有回调
    responseCallback = ^(id responseData) {
    if (responseData == nil) {
    responseData = [NSNull null];
    }

    WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
    [self _queueMessage:msg];
    };
    } else {
    responseCallback = ^(id ignoreResponseData) {
    // Do nothing
    };
    }
    WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];

    if (!handler) {
    NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
    continue;
    }
    //向下传递参数,并且触发block回调()
    handler(message[@"data"], responseCallback);
    }
    }
    }
    这个方法就是OC端处理JS的核心方法了,将messageQueueString反序列化,得到消息数组
    1
    2
    3
    4
    5
    6
    7
    8
    9
    (
    {
    callbackId = "cb_1_1639553291614";
    data = {
    foo = bar;
    };
    handlerName = testObjcCallback;
    }
    )
    callbackId,表明有消息回调,生成responseCallbackBlock,这个Block里面将接收的参数与callbackId打包一并发送给JS环境,并调用JS环境的WebViewJavascriptBridge._handleMessageFromObjC(messageJSON);方法将messageJSON进行解析。这里只是Block的实现,并没调用这个Block,调用在下面
1
2
3
4
5
6
7
8
WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];

if (!handler) {
NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
continue;
}
//向下传递参数,并且触发block回调()
handler(message[@"data"], responseCallback);

根据handlerName找到在messageHandlers保存的方法实现,handler(message[@"data"], responseCallback);进行真正的调用,在OC的注册方法中,调用responseCallback(@"Response from testObjcCallback"); 向JS环境发送回调并传递参数。

  • JS环境通过这个_handleMessageFromObjC(messageJSON)方法得到messageJSON,并对其解析。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    function _dispatchMessageFromObjC(messageJSON) {
    if (dispatchMessagesWithTimeoutSafety) {
    setTimeout(_doDispatchMessageFromObjC);
    } else {
    _doDispatchMessageFromObjC();
    }

    function _doDispatchMessageFromObjC() {
    //转换为对象
    var message = JSON.parse(messageJSON);
    var messageHandler;
    var responseCallback;
    // 这个responseId就是JS调用OC方法保存的callbackId
    if (message.responseId) {
    responseCallback = responseCallbacks[message.responseId];
    if (!responseCallback) {
    return;
    }
    responseCallback(message.responseData);
    delete responseCallbacks[message.responseId];
    } else {
    if (message.callbackId) {
    var callbackResponseId = message.callbackId;
    responseCallback = function(responseData) {
    _doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
    };
    }

    var handler = messageHandlers[message.handlerName];
    if (!handler) {
    console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
    } else {
    handler(message.data, responseCallback);
    }
    }
    }
    }

    先将字符串转化为JSON对象,根据responseId(这个responseId就是JS调用OC方法保存的callbackId),找到对应的方法实现,进行调用

    1
    2
    3
    function(response) {
    log('JS got response', response)
    }

    到此就完成了JS调用OC,并且OC回调JS并传递参数的全部过程。

2. OC调用JS方法

跟上面类似再来看下OC主动调用JS方法的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
[_bridge callHandler:@"testJavascriptHandler" data:@{ @"foo":@"before ready" }];
....
- (void)callHandler:(NSString *)handlerName data:(id)data {
[self callHandler:handlerName data:data responseCallback:nil];
}

- (void)callHandler:(NSString *)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback {
[_base sendData:data responseCallback:responseCallback handlerName:handlerName];
}

....

- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName {
NSMutableDictionary* message = [NSMutableDictionary dictionary];

if (data) {
message[@"data"] = data;
}

if (responseCallback) {
NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
self.responseCallbacks[callbackId] = [responseCallback copy];
message[@"callbackId"] = callbackId;
}

if (handlerName) {
message[@"handlerName"] = handlerName;
}
[self _queueMessage:message];
}
....

- (void)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
[self _log:@"SEND" json:messageJSON];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
NSLog(@"javascriptCommand==%@",javascriptCommand);
if ([[NSThread currentThread] isMainThread]) {
[self _evaluateJavascript:javascriptCommand];

} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self _evaluateJavascript:javascriptCommand];
});
}
}

可以看到,跟JS调用OC方法的原理类似,将OC调用JS的方法名与参数封装进message对象,如果有回调函数,将回调函数通过responseCallbacks保存,并生成callbackId,将整个message打包发送给JS环境的WebViewJavascriptBridge._handleMessageFromObjC(messageJSON); 进行解析,解析流程上面介绍过了,这里不再赘述。

四. 总结

通过上面流程分析,整个WebViewJavascriptBridge 内部的实现原理就比较清晰了。

  • JS将方法注册到JS环境的bridge,OC调用JS的核心方法就是 [_webView evaluateJavaScript:javascriptCommand completionHandler:nil];,JS环境收到消息后,通过方法WebViewJavascriptBridge._handleMessageFromObjC();将消息进行解析,调用注册在bridge的方法。
  • OC将方法注册到OC环境的bridge,JS调用OC的核心逻辑是,设置空白网页的src属性,从而被webView的代理方法- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler,OC通过核心方法- (void)flushMessageQueue:(NSString *)messageQueueString将传递数据进行解析。
  • 两边都是通过方法名找到对应的方法实现,然后通过ID来查找回调函数。

多阅读源码是好的一方面可以提升自己源码阅读水平,另一方面可以学习作者的一些好的设计思路。

参考:

https://github.com/marcuswestin/WebViewJavascriptBridge

https://github.com/lzyzsd/JsBridge

https://juejin.cn/post/6844903472718938126#heading-9

iOS-逆向之从砸壳到重签名

一、概述

笔者做了多年的业务开发,以前对逆向知之甚少,好像我们做iOS的开发者对应用本身的安全问题总不是那么上心,总以为Apple自身的加密签名机制足够安全了,我们除了关心业务网络安全,对自身App包的安全重视度总是不够。然而一旦我们的包被有心人破解,那么无论是对自身业务还是用户数据都是致命的。真正开始研究逆向,才发现我们App是如此的不堪一击。所以研究逆向其实是研究Hacker是如何攻破我们的应用,从而更好地保护自身应用安全。

研究逆向,首当其冲的就是重签名,重签名就是用自己的证书签名别人的应用,从而进行分发。重签名说白了就是套壳别人的应用,挺火的微信双开,其实就是对微信进行了重签名。而重签名首先要做的就是对应用砸壳。

二、手机越狱与应用砸壳

我们从App store 下载的应用是经过苹果加密签名过的,是无法进行重签名的。所以重签名首先要做的就是对应用进行砸壳,而砸壳需要你有一台越狱手机。

1. 手机越狱

目前主流的越狱平台有PP助手、爱思助手做到比较好,提供一键越狱功能。我自己使用的爱思助手,下载爱思助手电脑版,选择【工具箱】-【一键越狱】,此时会匹配你当前手机版本的越狱工具,选择工具进行越狱。
pic
我使用的是 iphone SE,系统版本是14.4,按照提示完成越狱就OK了。

需要注意,这种越狱方式是不完美的越狱,当你重启手机后会重新变回非越狱状态,需要重新走一遍越狱流程。不过对于我们研究逆向足够了。

还没完,越狱完成后需要安装一个插件:Apple File Conduit"2",安装这个插件的目的是,让我们的电脑端能访问设备的根文件目录,安装方式也很简单,可以参考这篇文章Apple File Conduit”2”安装,这里不再赘述。当我们安装完成后,爱思助手连接手机,可以看到在【文件管理】中多了个栏目【文件系统(越狱)】,这就是越狱状态下的系统根目录。

pic

2. ipa应用砸壳

首先了解什么是应用砸壳:我们提交到App Store发布的App都是经过Apple加密的,这样可以确保安装到我们手机的应用都是苹果审核授权的,当然通过企业级证书或者开发者证书生成的App是不需要砸壳的。对于App Store加密的应用,我们无法通过Hopper等反编译静态分析,也无法class-dump,在逆向分析过程中,需要对加密的二进制文件进行解密才可以进行静态分析,这一过程就是大家熟知的砸壳。
砸壳主要有两种方式:

  • 静态砸壳
    静态砸壳就是在已经掌握和了解到了壳应用的加密算法和逻辑后,在不运行壳应用程序的前提下将壳应用程序进行解密处理。静态脱壳的方法难度大,而且加密方发现应用被破解后,就可能会改用更加高级和复杂的加密技术。
  • 动态砸壳
    动态砸壳就是从运行在进程内存空间中的可执行程序映像(image)入手,来将内存中的内容进行转储(dump)处理来实现脱壳处理。这种方法实现起来相对简单,且不必关心使用的是何种加密技术。所以目前市面上的砸壳工具都是基于动态砸壳进行的。动态砸壳有多种工具:ClutchdumpdecryptedCrackerXI App,前两种对系统要求较严格,而且年久失修很容易砸壳失败,我们直接介绍最方便的第三种方式:利用CrackerXI App进行砸壳。
(1) 在Cydia中下载CrackerXI App

Cydia类似于越狱前的App Store,越狱后我们所有的软件都是通过Cydia来进行安装。打开Cydia后,选择【软件源】-右上角【编辑】-点击【添加】,输入http://apt.wxhbts.com/,【添加源】等待添加完成。添加软件源完成后,搜索CrackerXI App,点击安装完成后【重启springboard】,回到桌面,可以看到桌面上安装完成了CrackerXI+App.

pic

(2) 砸壳

提前在App Store 下载好你要砸壳的App,打开CrackerXI+,选择【AppList】然后点击你要砸壳的应用,在弹框中选择【YES,Full IPA】,此时会打开我们要砸壳的应用进行砸壳,完成后会看到一个砸壳后的文件地址/var/mobile/Documents/CrackerXI/*****.ipa,这个文件就是我们砸壳后的ipa包。

pic

这个路径怎么查找相信聪明的你已经知道了,去爱思助手-【文件管理】-【文件系统(越狱)】查找到这个ipa,导出到桌面目录就完成了。

pic

(3) 验证

完成上面的操作后,我们需要验证一下拿到的ipa是否是被砸壳的。把.ipa包扩展名改为.zip,解压后得到Payload文件夹,右键【显示包内容】- 找到可执行文件.

pic

终端输入

1
otool -l 值执行文件名 | grep crypt

pic

可以看到cryptid的值为0,说明砸壳成功。

三、重新签名

Apple 应用的分发一般有一下几种方式:

  • 最常用的是从App store下载应用。这种下发方式不受设备数的限制,只要上线App store 的应用,都会被Apple进行签名加密.
  • 第二种方式是申请企业账号,把我们的应用通过企业账号进行签名,从而绕过App store恼人的审核机制,达到分发应用的目的。这种方法分发数目也不受限制.
  • 第三种是通过TestFlight进行测试版本的分发,他分为内部测试人员与外部测试人员。通过分发外部测试人员,最多能给1万名用户进行分发安装.
  • 第四种开发人员通过添加设备ID安装应用,最多可以注册100台设备.

不同的开发者账号对应着App不同的分发方式,我们申请完成开发者账号后,创建应用Id,然后创建其对应证书,描述文件等一系列动作,实际已经决定了它的分发方式了。可以这样理解:每一个应用Id后面对应一套证书,这套证书决定了你应用的分发方式。重签名就是为当前的应用换一套应用Id与证书,从而达到分发应用的目的。现实的需求是,如果你上线App Store的应用,想通过企业账号的形式进行分发,而你又没有源码,或是想探究一下应用双开,那么重签名就派上用场了。我们介绍两种重签名的方式:

1.Xcode 重签名

(1) 新建同名的工程文件

注意这里的同名并不是Bundle Identifier 相同,而是跟你砸壳解压ipa文件,Payload里面的包相同的名称

还需要注意,如果你重签名的工程中是通过AppDelegate来监听App的生命周期的话,那么需要在新版的Xcode中移除SceneDelegate这个类,重新使用AppDelegate来监听App的生命周期。重新配置完成工程后,真机运行,把描述文件安装到手机里。

pic

(2) 替换编译的App包

找到我们砸壳过的Payload文件夹中包,对我们编译的包进行替换

pic

(3) 对二进制文件中的FrameWork进行重签名

其实在这一步之前还需要对包内的PlugIns插件以及Watch相关组件进行删除,我们逆向包里面没有这些组件,所以省略了。

进入Framework 文件夹,利用CodeSign对Framework进行证书签名,注意要对所有FrameWork进行重签名。

pic

1
codesign -fs "复制的你自己的证书名字" 要重签的FrameWork名称

证书的名字就是你真机测试的证书的名称,如果不知道可以去钥匙串中查看

pic

iPhone Developer: *** ** (********) 就是证书名称

(4) 重签名后运行

pic

这样我们就在没有源码的情况下,完成了对应用的重签名。

2. 使用脚本文件重签名

利用shell脚本进行重签名的原理,跟上面的签名原理相同,只不过把重签步骤给脚本化了。

(1) 创建空工程(工程名随便),并且进行真机运行
(2) 在工程根目录下创建APP文件夹,在文件中放入我们砸壳后的ipa包

pic

(3) 在工程中添加脚本

在工程Build Phases中新建脚本文件
pic

Run Script中添加脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# Type a script or drag a script file from your workspace to insert its path.
# Type a script or drag a script file from your workspace to insert its path.
# ${SRCROOT} 为工程文件所在的目录
TEMP_PATH="${SRCROOT}/Temp"
#资源文件夹,放三方APP的
ASSETS_PATH="${SRCROOT}/APP"
#ipa包路径
TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"

#新建Temp文件夹
rm -rf "$TEMP_PATH"
mkdir -p "$TEMP_PATH"

# --------------------------------------
# 1. 解压IPA 到Temp下
unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
# 拿到解压的临时APP的路径
TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
# 这里显示打印一下 TEMP_APP_PATH变量
echo "TEMP_APP_PATH: $TEMP_APP_PATH"

# -------------------------------------
# 2. 把解压出来的.app拷贝进去
#BUILT_PRODUCTS_DIR 工程生成的APP包路径
#TARGET_NAME target名称
TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
echo "TARGET_APP_PATH: $TARGET_APP_PATH"

rm -rf "$TARGET_APP_PATH"
mkdir -p "$TARGET_APP_PATH"
cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH/"

# -------------------------------------
# 3. 为了是重签过程简化,移走extension和watchAPP. 此外个人免费的证书没办法签extension

echo "Removing AppExtensions"
rm -rf "$TARGET_APP_PATH/PlugIns"
rm -rf "$TARGET_APP_PATH/Watch"

# -------------------------------------
# 4. 更新 Info.plist 里的BundleId
# 设置 "Set :KEY Value" "目标文件路径.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"

# 5.给可执行文件上权限
#添加ipa二进制的执行权限,否则xcode会告知无法运行
#这个操作是要找到第三方app包里的可执行文件名称,因为info.plist的 'Executable file' key对应的是可执行文件的名称
#我们grep 一下,然后取最后一行, 然后以cut 命令分割,取出想要的关键信息。存到APP_BINARY变量里
APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`

#这个为二进制文件加上可执行权限 +X
chmod +x "$TARGET_APP_PATH/$APP_BINARY"

# -------------------------------------
# 6. 重签第三方app Frameworks下已存在的动态库
TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
then
#遍历出所有动态库的路径
for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
do
echo "FRAMEWORK : $FRAMEWORK"
#签名
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
done
fi

运行后完成对其重签名.

四、总结

对应用的砸壳和重签名仅仅是逆向的入门,后面我们会继续探讨关于逆向的其他技术。再次声明:研究逆向的目的是为了更好的保护我们的应用,而不是用在非法用途上。

参考文献:

http://blog.cnbang.net/tech/3386/
https://www.i4.cn/news_3.html

https://www.i4.cn/news_detail_1623.html

iOS-浅谈UIView的刷新与绘制

topPic

概述:

UIView是我们在做iOS开发时每天都会接触到的类,几乎所有跟页面显示相关的控件也都继承自它。但是关于UIView的布局、显示、以及绘制原理等方面笔者一直一知半解,只有真正了解了它的原理才能更好的服务我们的开发。并且在市场对iOS开发者要求越来越高的大环境下,对App页面流畅度的优化也是对高级及以上开发者必问的面试题,这就需要我们要对UIView有更深的认知。

一.UIView 与 CALayer

UIView:一个视图(UIView)就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置,在iOS当中,所有的视图都从一个叫做UIView的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画。

CALayer:CALayer类在概念上和UIView类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和UIView最大的不同是CALayer不处理用户的交互。

CALayer并不清楚具体的响应链(iOS通过视图层级关系用来传送触摸事件的机制),于是它并不能够响应事件,即使它提供了一些方法来判断一个触点是否在图层的范围之内。

1. UIView 与 CALayer的关系

每一个UIView都有一个CALayer实例的图层属性,也就是所谓的backing layer,视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层也同样对应在层级关系树当中有相同的操作.

两者的关系:实际上这些背后关联的图层(Layer)才是真正用来在屏幕上显示和做动画,UIView仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation底层方法的高级接口。

这里引申出面试常问的一个问题:为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?

原因在于要做职责分离(单一职责原则),这样也能避免很多重复代码。在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么iOS有UIKitUIView,但是Mac OS有AppKitNSView的原因。他们功能上很相似,但是在实现上有着显著的区别。把这种功能的逻辑分开并封装成独立的Core Animation框架,苹果就能够在iOS和Mac OS之间共享代码,使得对苹果自己的OS开发团队和第三方开发者去开发两个平台的应用更加便捷。

2. CALayer的一些常用属性

contents属性

CALayer的contents属性可以让我们为layer图层设置一张图片,我们看下它的定义

1
2
3
4
5
6
/* An object providing the contents of the layer, typically a CGImageRef,
* but may be something else. (For example, NSImage objects are
* supported on Mac OS X 10.6 and later.) Default value is nil.
* Animatable. */

@property(nullable, strong) id contents;

这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app都能够编译通过。但是,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针,UIImage有一个CGImage属性,它返回一个CGImageRef,但是要使用它还需要进行强转:

1
layer.contents = (__bridge id _Nullable)(image.CGImage);
contentGravity属性
1
2
3
4
5
6
7
8
/* A string defining how the contents of the layer is mapped into its
* bounds rect. Options are `center', `top', `bottom', `left',
* `right', `topLeft', `topRight', `bottomLeft', `bottomRight',
* `resize', `resizeAspect', `resizeAspectFill'. The default value is
* `resize'. Note that "bottom" always means "Minimum Y" and "top"
* always means "Maximum Y". */

@property(copy) CALayerContentsGravity contentsGravity;

如果我们为图层layer设置contents为一张图片,那么可以使用这个属性来让图片自适应layer的大小,它类似于UIView的contentMode属性,但是它是一个NSString类型,而不是像对应的UIKit部分,那里面的值是枚举。contentsGravity可选的常量值有以下一些:

1
2
3
4
5
6
7
8
9
10
11
12
kCAGravityCenter
kCAGravityTop
kCAGravityBottom
kCAGravityLeft
kCAGravityRight
kCAGravityTopLeft
kCAGravityTopRight
kCAGravityBottomLeft
kCAGravityBottomRight
kCAGravityResize
kCAGravityResizeAspect
kCAGravityResizeAspectFill

例如,如果要让图片等比例拉伸去自适应layer的大小可以直接这样设置

1
layer.contentsGravity = kCAGravityResizeAspect;
contentsScale属性
1
2
3
4
5
6
7
8
9
/* Defines the scale factor applied to the contents of the layer. If
* the physical size of the contents is '(w, h)' then the logical size
* (i.e. for contentsGravity calculations) is defined as '(w /
* contentsScale, h / contentsScale)'. Applies to both images provided
* explicitly and content provided via -drawInContext: (i.e. if
* contentsScale is two -drawInContext: will draw into a buffer twice
* as large as the layer bounds). Defaults to one. Animatable. */

@property CGFloat contentsScale

contentsScale属性定义了contents设置图片的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。这个属性其实属于支持Retina屏幕机制的一部分,它的值等于当前设备的物理尺寸与逻辑尺寸的比值。如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,则会以每个点2个像素绘制图片。当用代码的方式来处理contents设置图片的时候,一定要手动的设置图层的contentsScale属性,否则图片在Retina设备上就显示得不正确啦。代码如下:

1
layer.contentsScale = [UIScreen mainScreen].scale;
maskToBounds属性

maskToBounds属性的功能类似于UIView的clipsToBounds属性,如果设置为YES,则会将超出layer范围的图片进行裁剪.

contentsRect属性

contentsRect属性在我们的日常开发中用的不多,它的主要作用是可以让我们显示contents所设置图片的一个子区域。它是单位坐标取值在0到1之间。默认值是{0, 0, 1, 1},这意味着整个图片默认都是可见的,如果我们指定一个小一点的矩形,比如{0,0,0.5,0.5},那么layer显示的只有图片的左上角,也就是1/4的区域。

实际上给layer的contents赋CGImage的值不是唯一的设置其寄宿图的方法。我们也可以直接用Core Graphics直接绘制。通过继承UIView并实现-drawRect:方法来自定义绘制,如果单独使用CALayer那么可以实现其代理(CALayerDelegate)方法- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;在这里面进行自主绘制。实际的方法绘制流程我们在下面进行探讨。

二.View的布局与显示

1.图像显示原理

在开始介绍图像的布局与显示之前,我们有必要先了解下图像的显示原理,也就是我们创建一个显示控件是怎么通过CPU与GPU的运算显示在屏幕上的。这个过程大体分为刘六个阶段:
绘制

  • 布局 :首先一个视图由CPU进行Frame布局,准备视图(view)和图层(layer)的层级关系,以及设置图层属性(位置,背景色,边框)等等。
  • 显示:view的显示图层(layer),它的寄宿图片被绘制的阶段。所谓的寄宿图,就是上面我们提到过的layer所显示的内容。它有两种设置形式:一种是直接设置layer.contents,赋值一个CGImageRef;第二种是重写UIView的drawRect:CALayerDelegatedrawLayer:inContext:方法,实现自定义绘制。注意:如果实现了这两个方法,会额外的消耗CPU的性能。
  • 准备:这是Core Animation准备发送数据到渲染服务的阶段。这个阶段主要对视图所用的图片进行解码以及图片的格式转换。PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片。
  • 提交:CPU会将处理视图和图层的层级关系打包,通过IPC(内部处理通信)通道提交给渲染服务,渲染服务由OpenGL ES和GPU组成。
  • 生成帧缓存:渲染服务首先将图层数据交给OpenGL ES进行纹理生成和着色,生成前后帧缓存。再根据显示硬件的刷新频率,一般以设备的VSync信号和CADisplayLink为标准,进行前后帧缓存的切换。
  • 渲染 :将最终要显示在画面上的后帧缓存交给GPU,进行采集图片和形状,运行变换,应用纹理和混合,最终显示在屏幕上。

注意:当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。

前四个阶段都在软件层面处理(通过CPU),第五阶段也有CPU参与,只有最后一个完全由GPU执行。而且,你真正能控制只有前两个阶段:布局和显示,Core Animation框架在内部处理剩下的事务,你也控制不了它。所以接下来我们来重点分析布局与显示阶段。

2.布局

布局:布局就是一个视图在屏幕上的位置与大小。UIView有三个比较重要的布局属性:frameboundscenter.UIView提供了用来通知系统某个view布局发生变化的方法,也提供了在view布局重新计算后调用的可重写的方法。

layoutSubviews()方法

layoutSubviews():当一个视图“认为”应该重新布局自己的子控件时,它便会自动调用自己的layoutSubviews方法,在该方法中“刷新”子控件的布局.这个方法并没有系统实现,需要我们重新这个方法,在里面实现子控件的重新布局。这个方法很开销很大,因为它会在每个子视图上起作用并且调用它们相应的layoutSubviews方法.系统会根据当前run loop的不同状态来触发layoutSubviews调用的机制,并不需要我们手动调用。以下是他的触发时机:

  • 直接修改 view 的大小时会触发
  • 调用addSubview会触发子视图的layoutSubviews
  • 用户在 UIScrollView 上滚动(layoutSubviews 会在UIScrollView和它的父view上被调用)
  • 用户旋转设备
  • 更新视图的 constraints
    这些方式都会告知系统view的位置需要被重新计算,继而会调用layoutSubviews.当然也可以直接触发layoutSubviews的方法。
setNeedsLayout()方法

setNeedsLayout()方法的调用可以触发layoutSubviews,调用这个方法代表向系统表示视图的布局需要重新计算。不过调用这个方法只是为当前的视图打了一个脏标记,告知系统需要在下一次run loop中重新布局这个视图。也就是调用setNeedsLayout()后会有一段时间间隔,然后触发layoutSubviews.当然这个间隔不会对用户造成影响,因为永远不会长到对界面造成卡顿。

layoutIfNeeded()方法

layoutIfNeeded()方法的作用是告知系统,当前打了脏标记的视图需要立即更新,不要等到下一次run loop到来时在更新,此时该方法会立即触发layoutSubviews方法。当然但如果你调用了layoutIfNeeded之后,并且没有任何操作向系统表明需要刷新视图,那么就不会调用layoutsubview.这个方法在你需要依赖新布局,无法等到下一次 run loop的时候会比setNeedsLayout有用。

3.显示

和布局的方法类似,显示也有触发更新的方法,它们由系统在检测到更新时被自动调用,或者我们可以手动调用直接刷新。

drawRect:方法

在上面我们提到过,如果要设置视图的寄宿图,除了直接设置view.layer.contents属性,还可以自主进行绘制。绘制的方法就是实现view的drawRect:方法。这个方法类似于布局的layoutSubviews方法,它会对当前View的显示进行刷新,不同的是它不会触发后续对视图的子视图方法的调用。跟layoutSubviews一样,我们不能直接手动调用drawRect:方法,应该调用间接的触发方法,让系统在 run loop 中的不同结点自动调用。具体的绘制流程我们在本文第三节进行介绍。

setNeedsDisplay()方法

这个方法类似于布局中的setNeedsLayout。它会给有内容更新的视图设置一个内部的标记,但在视图重绘之前就会返回。然后在下一个run loop中,系统会遍历所有已标标记的视图,并调用它们的drawRect:方法。大部分时候,在视图中更新任何 UI 组件都会把相应的视图标记为“dirty”,通过设置视图“内部更新标记”,在下一次run loop中就会重绘,而不需要显式的调用setNeedsDisplay.

三.UIView的系统绘制与异步绘制流程

UIView的绘制流程

接下来我们看下UIView的绘制流程
绘制

  • UIView调用setNeedsDisplay,这个方法我们已经介绍过了,它并不会立即开始绘制。
  • UIView 调用setNeedsDisplay,实际会调用其layer属性的同名方法,此时相当于给layer打上绘制标记。
  • 在当前run loop 将要结束的时候,才会调用CALayer的display方法进入到真正的绘制当中
  • 在CALayer的display方法中,会判断layer的代理方法displayLayer:是否被实现,如果代理没有实现这个方法,则进入系统绘制流程,否则进入异步绘制入口。

系统绘制

xitong

  • 在系统绘制开始时,在CALayer内部会创建一个绘制上下文,这个上下文可以理解为CGContextRef,我们在drawRect:方法中获取到的currentRef就是它。

  • 然后layer会判断是否有delegate,没有delegate就调用CALayerdrawInContext方法,如果有代理,并且你实现了CALayerDelegate协议中的-drawLayer:inContext:方法或者UIView中的-drawRect:方法(其实就是前者的包装方法),那么系统就会调用你实现的这两个方法中的一个。

    关于这里的代理我的理解是:如果你直接使用的UIView,那么layer的代理就是当前view,你直接实现-drawRect:,然后在这个方法里面进行自主绘制; 如果你用的是单独创建的CALayer,那么你需要设置layer.delegate = self; 当然这里的self就是持有layer的视图或是控制器了,这时你需要实现-drawLayer:inContext:方法,然后在这个方法里面进行绘制。

  • 最后CALayer把位图传给GPU去渲染,也就是将生成的 bitmap 位图赋值给 layer.content 属性。

    注意:使用CPU进行绘图的代价昂贵,除非绝对必要,否则你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。

异步绘制

什么是异步绘制?

通过上面的介绍我们熟悉了系统绘制流程,系统绘制就是在主线程中进行上下文的创建,控件的自主绘制等,这就导致了主线程频繁的处理UI绘制的工作,如果要绘制的元素过多,过于频繁,就会造成卡顿。而异步绘制就是把复杂的绘制过程放到后台线程中执行,从而减轻主线程负担,来提升UI流畅度。

异步绘制流程

pic
上面很明显的展示了异步绘制过程:

  • 从上图看,异步绘制的入口在layer的代理方法displayLayer:,如果要进行异步绘制,我们必须在自定义view中实现这个方法
  • displayLayer:方法中我们开辟子线程
  • 在子线程中我们创建绘制上下文,并借助Core Graphics 相关API完成自主绘制
  • 完成绘制后生成Image图片
  • 最后回到主线程,把Image图片赋值给layer的contents属性。

当然我们在日常开发中还要考虑线程的管理与绘制时机等问题,使用第三方库YYAsyncLayer可以让我们把注意力放在具体的绘制上,具体的使用流程可以点这里去查看.

四.总结

我们知道,当我们实现了CALayerDelegate协议中的-drawLayer:inContext:方法或者UIView中的-drawRect:方法,图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽X图层高X4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048X15264字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。可见使用Core Graphics利用CPU进行绘制代价是很高的,那么如何进行高效的绘图呢?iOS-Core-Animation-Advanced-Techniques给出了答案,我们在日常开发中完全可以使用Core AnimationCAShapeLayer代替Core Graphics进行图形的绘制,具体的方法这里就不介绍了,感兴趣的可以自行去查看。

参考引用:
iOS-Core-Animation-Advanced-Techniques
YYAsyncLayer
https://juejin.cn/post/6844903567610871816

从客户端到前端入门总结

一. 概述

笔者是客户端研发出身,如果只有一门技术傍身,不足以胜任市场对客户端研发的要求,于是想学习大前端技术栈。但苦于日常工作繁琐,一直没有机会学习(其实就是自身懒惰)。直到工作需要,需要写一个微信小程序,这才下决心学习前端技术。其实如果想入门前端,从微信小程序入手不失为一个好的方法。初次接触微信小程序,它的数据双向绑定机制,让写习惯了客户端的我叹为观止。目前我入门前端的技术路径是:客户端—微信小程序—混合App—H5。一些我自己的经验总结出来,希望对你有所帮助。当然阅读这篇文章的前提是,你已经了解了基本的Html、CSS、JS语法。

二. 环境与工具

1. 前端环境搭建

笔者使用的Mac电脑,所有的环境搭建工作都是基于Mac电脑来操作的。首先安装node.jsnpm:

node.js

node 是一个基于 V8 引擎的 Javascript 运行环境,它使得 Javascript 可以运行在服务端,直接与操作系统进行交互,与文件控制、网络交互、进程控制等。简单的说node.js就是运行在服务端的 JavaScript。你可能会有疑问,我写前端页面为甚么需要一个运行在服务端的的JS环境。其实我们这里使用node最关键是需要安装npm.

npm

npm是node.js的包管理工具(package manager),为啥我们需要一个包管理工具呢?因为我们在Web开发时,会用到很多别人写的JavaScript代码。如果我们要使用别人写的某个包,每次都根据名称搜索一下官方网站,下载代码、解压、再使用,非常繁琐。于是一个集中管理的工具应运而生:大家都把自己开发的模块打包后放到npm官网上,如果要使用,直接通过npm安装就可以直接用。他类似于iOS开发中的Cocoapods,Android开发中的Maven,这样就好理解了。
node下载地址点击这里,按照步骤安装完成后,终端输入

1
2
node -v
npm -v

查看安装版本,出现下面的版本号说明安装成功。
node

注意如果提示-bash: node: command not found,说明还需要配置一下环境变量。配置方式也很简单,在Finder中查找文件夹,输入路径/private/etc,找到profile文件,加上一下语句

pic

1
2
export NODE_HOME="node安装路径(bin路径的父级路径)" 
export PATH=$PATH:$NODE_HOME/bin

node安装路径(bin路径的父级路径):你可以通过命令which node 命令来查看。例如我的本地路径是/usr/local/bin/node,那么可以这样设置

1
2
export NODE_HOME="/usr/local"
export PATH=$PATH:$NODE_HOME/bin

重新保存文件后,再次输入node -v 验证一下。下面是一些npm常用命令:

1
2
3
4
5
6
7
8
9
10
11
12
// 本地安装模块
npm install <Module Name>
// 全局安装模块
npm install <Module Name> -g
// 搜索模块
npm search <Module Name>
// 更新模块
npm update <Module Name>
//卸载模块
npm uninstall <Module Name>
// 安装项目的全部依赖
npm install
yarn:

yarn是一个由 Facebook 贡献的 Javascript 包管理器。yarn是为了弥补npm的一些缺陷而出现的。在日常开发中你可以使用npm也可以使用yarn进行包的管理,只不过相比npm而言,它的速度更快,并提供了离线模式。关于它我们不会过多的介绍,你可以去它的中文网站查看.它的安装方式也很简单,直接通过Homebrew进行安装,命令 brew install yarn. 它的一些常用指令:

1
2
3
4
5
6
7
8
9
10
11
12
// 初始化一个新项目
yarn init
// 添加依赖包
yarn add [package]
// 添加依赖包的某个版本
yarn add [package]@[version]
// 升级依赖包
yarn upgrade [package]
// 移除依赖包
yarn remove [package]
// 安装项目的全部依赖
yarn install 或者 yarn

可以在项目中混用yarnnpm,但是最好不要这样。

2. 开发工具选择

pic1
前端的开发工具基本就两个选择Visual Studio Code 或者 WebStorm,两者选择哪一个都可以,我个人更喜欢Visual Studio Code,其实选择它最主要原因是免费且开源,而且有强大的插件库。

VSCode安装插件:
chanjian

选择[扩展]-[搜索插件]-安装即可
下面是我使用的一些常用的VSCode插件:

(1) vue vscode snippets
它是Vue项目代码的骨架插件,例如输入vbase,会直接生成以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div>

</div>
</template>

<script>
export default {

}
</script>

<style lang="scss" scoped>

</style>

相似的还有vdatavmethodvfor等操作
(2) Auto Close Tag 自动闭合HTML标签
(3) Auto Rename Tag 修改HTML标签时,自动修改匹配的标签
(4) Color Highlight 颜色值在代码中高亮显示
(5) HTML CSS Class Completion CSS class提示插件
(6) Vetur Vue多功能集成插件,包括:语法高亮,智能提示,emmet,错误提示,格式化,自动补全,debugger。vscode官方钦定Vue插件,Vue开发者必备
(7) VSCode-icons 文件图标,方便定位文件
(8) Color Highlight 颜色值在代码中高亮显示
(9) HTML CSS Support 智能提示CSS类名以及id
(10) Beautify 格式化代码工具,美化javascript,JSON,CSS,Sass,和HTML
(11) Open in Browser 直接在浏览器中打开你当前的文件

三.Vue项目搭建与开发事项

目前前端几大主流框架ReactVueAngular,三个框架各有优劣,个人感觉Vue的入门难度最小,而且有良好的中文教程和广泛的第三方支持,如果要入门前端技术,从Vue入手是比较明智的。

1.Vue项目搭建

使用Vue CLI脚手架构建Vue项目

(1). 使用 npm 或 yarn 全局安装 Vue CLI
1
2
3
npm install -g @vue/cli
# OR
yarn global add @vue/cli

如果页面报错如下
error
说明执行权限不够,可以在在前面加sudo

1
sudo npm install -g @vue/cli

执行完成后输入命令vue -V查看Vue/Cli 版本,出现如下提示说明安装成功。
pic

(2). 在工程所在目录创建项目

执行以下命令

1
2
3
vue create my-project
# OR
vue ui

vue create 是使用命令行创建项目,vue ui是以图形化界面创建和管理项目。

(3). 配置工程

输入创建命令后提示:
pic

此时会判断你的npm/yarn源的连接速度,询问你是否切换至淘宝镜像,我们输入n,继续

pic

提示:项目是使用默认配置(Vue2还是Vue3 都包含babel, eslint)还是手动选择,我们选择手动配置,继续

pic

  • Choose Vue version: 选择Vue的版本 选择
  • Babel :将脚手架中浏览器不认识的一切东西翻译为浏览器认识的 选择
  • TypeScript : 强类型的 JaveScript
  • Progressive Web App (PWA) Support : 渐进式App,主要用于兼容移动端
  • Router : Vue 路由管理 选择
  • Vuex: Vue的状态管理器 选择
  • CSS Pre-processors : CSS 预处理器,可选择使用 less、sass、stylus等预处理器 选择
  • Linter / Formatter :代码检测和格式化
  • Unit Testing: 单元测试
  • E2E Testing: 端到端测试
    选中好后继续

    按方向键是进行上下移动,空格是选中/取消,回车是确定当前所选内容,继续下一步操作

pic

提示:选择Vue版本,我们选择Vue3版本,继续

pic

提示:路由方式是否使用history模式。一般都是单页面开发不选择history,输入n继续

pic

提示:选择CSS预处理器

  • node-sass 是自动编译,实时的
  • dart-sass 需要保存后才会生效
  • Less 最终会通过编译处理输出css到浏览器,Less 既可以在客户端上运行,也可在服务端运行 (借助 node.js)
  • Stylus 主要用来给node项目进行CSS预处理支持,Stylus功能上更为强壮,和Js联系更加紧密,可创建健壮的、动态的的CSS

我们选择 Sass/SCSS (with node-sass),继续

pic

提示:Babel, ESLint是使用独立文件,还是在package.json一个文件中保存所有配置信息。选择第一个,继续

pic

提示:是否为以后创建的项目保存我们当前所选的这些配置,我们选择,开始自动创建项目

pic

项目创建完成后,cd到当前工程目录下,执行yarn serve,就可以运行当前项目了。

pic

在浏览器中输入上面的地址就可看到我们当前创建的Vue工程了

pic

至此,我们整个工程创建完成。接下来总结一下在移动Web开发中常用的三方库。

2.第三方库使用总结

(1).移动Web布局库

我们在进行移动Web页面开发时,需要对页面进行布局,常用的布局方式有用rem来作单位,配合h5新的meta属性来适配屏幕做开发的,也有直接使用三方库postcss-px-to-viewport来进行页面布局的。我们直接使用第二种方式:
使用yarn进行安装,cd 到工程目录后执行

1
$ yarn add -D postcss-px-to-viewport

执行完成后,在postcss.config.js中进行参数配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
plugins: {'postcss-px-to-viewport': {
unitToConvert: 'px',// 要转化的单位
viewportWidth: 375,// UI设计稿的宽度
unitPrecision: 5,// 转换后的精度,即小数点位数
propList: ['!*'],// 指定转换的css属性的单位,*代表全部css属性的单位都进行转换
viewportUnit: 'vw',// 指定需要转换成的视窗单位,默认vw
fontViewportUnit: 'vw',// 指定字体需要转换成的视窗单位,默认vw
selectorBlackList: [],// 指定不转换为视窗单位的类名,
minPixelValue: 1,// 默认值1,小于或等于1px则不进行转换
mediaQuery: false,// 是否在媒体查询的css代码中也进行转换,默认false
replace: true,// 是否转换后直接更换属性值
exclude: [],// 设置忽略文件,用正则做目录名匹配
landscape: false,// 是否处理横屏情况
landscapeUnit: 'vw',//横屏单位
landscapeWidth: 568//横屏宽度
}}
}
(2). UI库

pic

在进行移动Web开发中,第三方UI库的使用是少不了的,我们最常用的有vantElement UI等,我们以vant来说明,进行工程目录执行

1
2
3
4
5
6
7
8
# Vue 2 项目,安装 Vant 2
npm i vant -S

# Vue 3 项目,安装 Vant 3
npm i vant@next -S

# 通过 yarn 安装vant3
yarn add vant@next

注意对于Vue2和Vue3项目引入方式是不一样的,我们是Vue3项目,因此执行npm i vant@next -S或者yarn add vant@next

安装完成后就可以引入组件了,也需要注意,Vue2与Vue3的配置方式也是不同的,具体可以去查看Vant官网查看,这里不在赘述。

(3). 网络请求库

在项目中进行网络请求时,最常用的第三方库是axios,他的引入方式也很简单:

1
2
3
$ npm install axios
OR
$ yarn add axios

对于Vue2项目而言安装完成后在mian.js中引用axios,并绑到原型链上。使用插件的时候,一般要在入口文件main.js中引入,因为mian.js是项目首先运行的文件:

1
2
3
import Vue from 'vue'
import axios from ‘axios’
Vue.prototype.$http = axios

它的用法很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// get请求
axios.get('/user?ID=12345')
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
// post 请求
axios.post('/user', {
firstName: 'Fred',
lastName: 'Flintstone'
})
.then(function (response) {
console.log(response);
})
.catch(function (error) {
console.log(error);
});
// 执行多个并发请求
function getUserAccount() {
return axios.get('/user/12345');
}

function getUserPermissions() {
return axios.get('/user/12345/permissions');
}

axios.all([getUserAccount(), getUserPermissions()])
.then(axios.spread(function (acct, perms) {
// 两个请求现在都执行完成
}));

但是对于Vue3项目而言,除了引入axios外,还需要引入vue-axios,引入方式:

1
$ npm install --save axios vue-axios

vue-axios是将axios集成到Vue.js的小包装器,可以像插件一样进行安装。在mian.js中引用axios,vue-axios,通过全局方法Vue.use()使用插件:

1
2
3
4
5
6
import { createApp } from 'vue'
import axios from 'axios'
import VueAxios from 'vue-axios'// Vue3 使用 axios 需要配合 vue-axios 一起使用

// 全局引入组件
createApp(App).use(VueAxios, axios).mount('#app');

使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default {
name: 'App',
methods: {
getList() {
this.axios.get(api).then((response) => {
console.log(response.data)
})
// or
this.$http.get(api).then((response) => {
console.log(response.data)
})
}
}
}
(4).项目中的图片使用

在开发项目时,免不了要使用到图片,目前项目常用的矢量图片库非阿里的iconfont莫属了,它最大的好处是你可以像调整文字一样,设置图片的颜色和大小,而不用担心图片失真问题。它的使用方式也很简单:
把我们要使用的图片添加到项目中,然后点击下载到本地
pic
它有三种引入方式分别是:unicode 引用font-class 引用symbol 引用,我们只介绍symbol 引用,这也是官方最推荐的引用方式,相比前两种有如下特点:

  • 支持多色图标了,不再受单色限制。
  • 通过一些技巧,支持像字体那样,通过font-size,color来调整样式。
  • 兼容性较差,支持 ie9+,及现代浏览器。
  • 浏览器渲染svg的性能一般,还不如png。
    使用步骤如下:
第一步:拷贝下载文件iconfont.js到项目目录

在需要用到iconfont 的地方引入这个js文件目录

1
import '../utils/iconfont';
第二步:加入通用css代码(引入一次就行)
1
2
3
4
5
6
7
8
<style type="text/css">
.icon {
width: 1em; height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
</style>
第三步:挑选相应图标并获取类名,应用于页面:
1
2
3
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-xxx"></use>
</svg>

#icon-xxx 就是你选的图片类名
如果要调整大小、颜色、位置等

1
2
3
4
5
6
7
8
.icon {
font-size: 30px;
color: orange;
position:relative;
display:inline-block;
top: calc(12px + 0.15rem);
right: 6px;
}

注意:加入的图片最好是去掉填充色的,然后你才能修改颜色,否则设置颜色不生效。

四.总结

转眼做前端已经小半年了,越学习越感觉前端内容的复杂,越感觉自己所学知识的浅薄,写这篇文章也算是对这段时间的总结。这篇文章面向的是前端初学者,还请大神勿喷。当然由于作者水平有限,难免出现纰漏,如有问题还请不吝赐教。

参考:
postcss-px-to-viewport
element UI
axios
iocnfont
https://v3.cn.vuejs.org/
https://cli.vuejs.org/zh/guide/

混合开发入门 Vue结合Android/iOS开发仿京东项目App

三端把后台数据进行处理并且优雅的展示给用户
混合 Naitive 跟 web 技术进行开发移动应用

一.目前主流的混合开发方案

  1. faceBook :React、ReactNative
  2. 阿里: Week
  3. google:flutter
  4. 小程序开发
  5. 移动应用混合开发框架
  6. PWA(Porgress Web App)
    本课程:使用Vue框架 配合 android + iOS 的跨语言通信原理
    项目效果:http://imooc.hybrid.lgdsunday.club/
    需要掌握内容:Scss Vue全家桶 vue-cli3 脚手架、Vue动画 webpack打包、组件化思想、
    混合开发原理 跨语言通信机制
    前端项目的部署

二.混合开发原理

混合开发又叫 Hybrid App,混合了Native 和 web技术进行开发的应用
方案:

  1. 基于 WebView UI (JSBridge)的方案 主流方案:淘宝、微信、饿了么等,使用JSBridge进行与原生的通信,使用webView 进行页面渲染,本课程使用

  2. 基于 Native UI (ReactNative、weex):赋予web原生能力基础上,通过JSBridge 将JS 解析成虚拟节点树,来传递Native ,并使用Native进行渲染

  3. 小程序方案(微信、支付宝):对JSbridge 进行定制,各类JS逻辑与UI渲染层,形成了特殊的开发环境,加强web与Native 的融合程度,提升渲染效果。

三.Hybrid App 的技术原理

  1. Hybrid App 的本质

    在原生App中 使用 webView作为容器,来承载一个web页面
    (苹果要求原生页面与web页面的比重最少为2:8)上架结果取决于交互体验。

Hybrid App的核心:原生与 web端的双向通信层(跨语言解决方案)JSBridge

什么是 JSBridge : 一座用javaScript搭建起来的桥梁,一端是web,一端是Native

目的: 让Native 可以调用web的javaScript 代码,让web可以调用Native 的原生代码
展示架构:
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成63)/截屏2021-07-28 下午5.23.58.png
2. Android 与 Web 通讯

(1). 配置X5WebView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
private void init (Context context) {
this.mContext = context;

/**
* 基础配置
*/
initWebViewSettings();
initWebViewClient();
initChromeClient();

/**
* 构建 JSBridge 对象,这里提供的 JSBridge 字符串会被挂载到
* 网页中的 window 对象下面。
*
* 在网页中我们可以使用 window.AndroidJSBridge 拿到这个android 注入到web端的对象
*/
addJavascriptInterface(
new MyJaveScriptInterface(mContext, this),
"AndroidJSBridge");


}

/**
* 对 webview 进行基础配置
*/
private void initWebViewSettings () {
WebSettings webSettings = getSettings();
/**
* 允许加载的网页执行 JavaScript 方法
*/
webSettings.setJavaScriptEnabled(true);
/**
* 设置网页不允许缩放
*/
webSettings.setSupportZoom(false);
webSettings.setBuiltInZoomControls(false);
webSettings.setDisplayZoomControls(true);
/**
* 设置网页缓存方式为不缓存,方便我们的调试
*/
webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE);
}

/**
* 设置 webviewClient ,如果不进行这层设置,则网页打开默认会使用
* 系统中的浏览器进行打开,而不是在本 APP 中进行打开。
*/
private void initWebViewClient () {
setWebViewClient(new WebViewClient(){
});
}

/**
* 监听网页中的url加载事件
*/
private void initChromeClient () {
setWebChromeClient(new WebChromeClient(){

/**
* alert()
* 监听alert弹出框,使用原生弹框代替alert。
*/
@Override
public boolean onJsAlert(WebView webView, String s, String s1, JsResult jsResult) {

AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setMessage(s1);
builder.setNegativeButton("确定", null);
builder.create().show();
jsResult.confirm();

return true;
}
});
}

(2). 初始化使用腾讯X5内核webView控件 X5WebView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
 public class MainActivity extends AppCompatActivity {

private X5WebView mWebView;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

init();
}

/**
* 初始化 webview
*/
private void init () {
mWebView = findViewById(R.id.web_view);
mWebView.loadUrl(Constants.WEB_URL);
}


/**
* 原生端调用 web 方法,方法必须是挂载到 web 端 window 对象下面的方法。
* 调用 JS 中的方法:onFunction1
*/
public void onJSFunction1 (View v) {
mWebView.evaluateJavascript("javascript:onFunction('android调用JS方法')", new ValueCallback<String>() {
@Override
public void onReceiveValue(String s) {
AlertDialog.Builder builder = new AlertDialog.Builder(MainActivity.this);
builder.setMessage(s);
builder.setNegativeButton("确定", null);
builder.create().show();
}
});
}
}

(3). 定义一些 Native 的接口供JS 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class MyJaveScriptInterface {

private Context mContext;
private X5WebView mWebView;

public MyJaveScriptInterface(Context context, X5WebView x5WebView) {
this.mContext = context;
this.mWebView = x5WebView;
}

/**
*
* window.AndroidJSBridge.androidTestFunction1('xxxx')
* 调用该方法,APP 会弹出一个 Alert 对话框,
* 对话框中的内容为 JavaScript 传入的字符串
* @param str android 只能接收基本数据类型参数
* ,不能接收引用类型的数据(Object、Array)。
* JSON.stringify(Object) -> String
*/
@JavascriptInterface
public void androidTestFunction1 (String str) {
AlertDialog.Builder builder = new AlertDialog.Builder(mContext);
builder.setMessage(str);
builder.setNegativeButton("确定", null);
builder.create().show();
}

/**
* 调用该方法,方法会返回一个返回值给 javaScript 端
* @return 返回值的内容为:"androidTestFunction2方法的返回值"
*/
@JavascriptInterface
public String androidTestFunction2 () {
return "androidTestFunction2方法的返回值";
}

}

注意点:
需要安装 npm 跟 node,然后安装 http-server(简单的,命令行 http服务器) : 提供给客户端一个可访问的地址。
安装命令npm install http-server -g
使用:

  • cd index.html 目录下
  • 执行命令http-server,得到运行地址,
  • 拿到地址后 拷贝到浏览器地址,运行(在 android 9.0 的设备上,如果要加载 http 协议的网页,那么需要对 app 进行安全访问设置。))
  • res/xml/network_security_config.xml 进行配置
    1
    2
    3
    4
    5
    6
    7
    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
    <domain-config cleartextTrafficPermitted="true">
    <!-- 访问 网页的 IP 地址 -->
    <domain includeSubdomains="true">192.168.0.100</domain>
    </domain-config>
    </network-security-config>
  1. IOS 与 Web 通讯

原理: 通过webView 组件 向window中 注入JSBridge 对象,web通过这个对象 调用原生的方法。

  1. 两端与Web通讯的相互对比
    相同点:
  • 都是通过webView来完成网页的加载的

  • 都是通过向window 注入对象的方式来提供可被web端调用的方法

  • 都可以调用 Web端挂载到window对象下的方法
    不同点

  • 注入对象的不同:Android 可提供注入对象的名称,IOS 固定为webkit

  • JS 调用 Native 方式不同:面向 Android 可直接获取注入对象,调用方法。面向 IOS 为相对固定写法(window.webkit.messageHandlers.方法名.postMessage(入参对象))

  • 传递数据格式不同:面向 Android 只能接受 基本数据 类型数据。面向 IOS 可以接受任意类型数据。

  • 返回值不同:面向 Android 可以直接接收返回值。面向 IOS 没有办法直接获取返回值(可以通过回调方法的方式进行)。

三.Hybrid App前段基础知识点

1. Flex布局

子项目属性

  • order:项目在容器中的排列顺序,数值越小排名越靠前。
  • flex-grow:通过一个数值来定义项目的放大比例,默认为0,即存在剩余空间也不进行放大
  • align-self:使一个项目与其他项目在交叉轴上拥有不同的对齐方式。

2. Scss预处理器

  • scss 是sass 3.0后的称呼,强化了CSS的辅助工具,在Css的语法那个增加了额外的功能(嵌套、变量、运算、函数)
  • 它无法被浏览器直接识别,webpack 已经帮我们转成CSS了。

(1)嵌套

1
2
3
<div class="box">
<p classs="box-desc"> 测试scss</p>
</div>

scss

1
2
3
4
5
6
.box {
&-box {

}
}

& 符号 表示其直接父标签,& == box

(2) 变量

  • 用来存储 css 中复用的一些信息
    定义一个变量:$titleSize:32px; 使用$符号 直接定义变量。在scss 中直接使用
1
2
3
&-box {
font-size:$titleSize;
}

(3)函数运算

  • 允许用户定义函数,计算想要的结果

定义函数需要@符号,变量值使用 $标记

1
2
3
4
@function doublePx ($px) {
@return $px *2 + px;
}
$titleSize: doublePx(16);

使用

1
2
3
&-box {
font-size:$titleSize;
}

其他关于Scss 去官网查看

3. Webpack 模块打包器

  • 是JavaScript 应用程序的静态模块打包器
  • 把开发时的多个模块合并成一个或者指定的几个文件

webpack 官方图
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成75)/截屏2021-08-09 下午4.39.05.png

浏览器在加载我们代码文件时,不可能下载多个模块或文件,这样或增加浏览器请求次数,延缓页面加载时间,
并且还会有sass文件不被浏览器解析,这时就用到了webpack,用来打包这些资源模块。
把开发时候的各种文件打包成有限的几个模块。

4. @vue/cli3 脚手架

  • 基于webpack 构建的,带有基础webpack配置脚手架的工具
  • 可以用 vue/cli 快速生成一个 Vue 项目的基础结构

使用 @vue/cli3

  • npm 安装 @vue/cli3:(sudo) npm install @vue/cli -g 全局安装
  • vue create 项目名称,直接创建Vue 项目
    实际安装创建
    /var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成76)/截屏2021-08-09 下午4.49.12.png
    (1) 输入命令行后创建Vue项目
  • 默认配置
  • 手动选择配置
    (2)选择手动
    /var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成77)/截屏2021-08-09 下午4.50.27.png

手动选择项目所依赖的模块(按空格选择)

  • ’Babel‘:必选 帮助我们把JS代码降级,可以把ES6代码编译成ES5
  • ’TypeScript‘: 不选
  • ’PWA‘ : Processive Web App Support:渐进式的增强Web应用 不选
  • ’Router‘ : Vue 中的路由 选择
  • ’Vuex‘ : Vue 中的状态管理器 选择
  • ’CSS Pre-process‘ : 选择
  • ‘Linter / Formatter’:代码风格检测器 保留 选择
  • ’Unit Testing‘ 测试相关
  • ‘E2E Testing’ 测试相关

(3) 是否选择
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成78)/截屏2021-08-09 下午4.56.09.png
’Use history mode for routur?‘ 选择Y,生产环境下配置争取的服务器地址

(4)选择代码风格的配置 选择 ’ESlint+Standard config‘
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成79)/截屏2021-08-09 下午4.59.11.png

(5) 选择代码检测,是否在保存 还是 提交的时候,选择保存时候
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成80)/截屏2021-08-09 下午5.00.28.png

(6)
这些依赖工具的配置文件 是希望各自拥有一个 还是 统一处理,选择第一个
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成82)/截屏2021-08-09 下午5.01.25.png
(7)是否保存为 预设配置,Y、N

/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成83)/截屏2021-08-09 下午5.02.21.png

预设可添加名字
/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成84)/截屏2021-08-09 下午5.03.11.png

(8)cd 到项目下, 通过’npm run serve‘ 进行启动项目,根据路径进行访问。一个本地路径,一个局域网路径

/var/folders/75/0xr1m5c13f7b5qsm139djwjmwm3q06/T/TemporaryItems/(screencaptureui正在存储文稿,已完成85)/截屏2021-08-09 下午5.05.02.png

5.rem兼容性设置

我们想要一套代码设配多种不同像素的设备。
达到效果:

相同的文字、图片大小,在不同的设备上应该展示不同的像素值

使用 rem:相对于标签 fontSize 大小的单位

  1. 根据屏幕宽度定义根元素fontSize大小
  2. 定义一个函数,把像素转换我rem。 输入像素,转换为rem

fontSize 计算规则

  1. 动态计算fontSize 大小
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // ES6 语法,直接原先需要在支持ES6 的浏览器中,比如chrome
    // 定义最大的 fontSize,避免屏幕分辨率过大导致 fonSize 过大
    const MAX_FONT_SIZE = 42;
    //监听 HTML文档被解析完成的事件
    document.addEventListener('DOMContentLoaded',() => {
    // 获取 html 标签
    const html = document.querySelector('html');
    // 屏幕的宽度除以10,获取根元素 fontSize 标准
    let fontSize = window.innerWidth / 10;
    // 获取到的fontSize 标准不允许超过我们定义的最大值
    fontSize = fontSize > MAX_FONT_SIZE ? MAX_FONT_SIZE : fontSize;
    // 定义根元素 fontSize 的大小
    html.style.fontSize = fontSize + 'px';
    });

  2. 函数转换 px -> rem
    1
    2
    3
    4
    5
    6
    7
    /* 如果 设计图以 iPhone 6、6S、7、8  375* 667逻辑像素为设计基础
    定义预计根元素 fontSize */
    $rootFontSize: 375 / 10;
    /* 定义像素转换为 rem 函数 */
    @function px2rem ($px) {
    @return $px / $rootFontSize + rem;
    }

npm run serve 后报错:

报错 Failed to resolve loader: sass-loader 。webpack 需要使用这个sass-loader 工具去转换scss文件。

安装:npm install sass-loader node-sass --save-dev 安装 sass-loadernode-sass
--save-dev :开发环境

Web开发常用布局方式总结

网络图片

一.概述

无论是前端还是客户端开发都离不开页面的展示,而页面是由布局和各种组件构成的。布局好比是建筑里的框架,而组件则相当于建筑里的砖瓦。组件按照布局的要求依次排列,就组成了用户所看见的界面。比如Android开发者常用的布局方式有:线性布局(LinearLayout)、相对布局(RelativeLayout)、绝对布局(AbsoluteLayout)、网格布局(GridLayout)等,IOS开发者常用的布局方式:手写Frame、自动布局(AutoLayout)、xib、storyboard等方式,我们这里主要介绍前端的布局方式。虽然目前大多数网站的构建都是通过成熟的框架搭建的,但是作为前端开发的基础,学习了解一下布局还是很有必要的。

页面布局(Layout):就是对页面的文字、图形或表格进行排布、设计. — 知乎

研究布局的目的是让我们的页面尽可能的还原UI设计给我们的设计图,适配各种尺寸的屏幕,使其在各种尺寸屏幕上能很好地显示出我们的视图。

我们来看下我们Web开发中常用的几种布局类型:

二.布局类型

1. 普通流布局

这是页面默认布局的方式 ,每个元素都有默认空间,每个元素都是在父元素的左上角出现的,页面中的块元素都是按照从上到下的方式出现,每个块元素独占一行,页面中行内元素都是从左到右的方式排列.
每个元素都有一个默认的 display 值,这与元素的类型有关。对于大多数元素它们的默认值通常是 blockinline 。一个 block 元素通常被叫做块级元素。一个 inline 元素通常被叫做行内元素。比如 div 是一个标准的块级元素,页面在进行渲染时候,遇到块级元素会另起一行,而行内元素会在当前行进行展示。举个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
html
<head>
<title>标题</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: white;
}
div {
border: 1px solid red;
}
span {
border: 1px solid green;
}
body :nth-child(4) {
display: inline-block;
background-color:salmon;
}
</style>
</head>
<body>
<div>这是一个div块元素</div>
<span>这是一个行内元素</span>
<span>这是另一个行内元素</span>
<div>
这是行内块元素
</div>
<div>这是还是一个div块元素</div>
</body>

图片1

  • 可以看到div元素是单独占一行的,而行内元素span不会换行。其他常用的块级元素包括 ph1——h6form 以及Html5中的新元素: headerfootersection 等等。常用的行内元素ainputimglabel等。
  • display 的取值除了为blockinline 外还可以取值inline-block,它表示行内块元素,如果一个块元素的display属性取值为inline-block,那么他就不在换行,比如我们这里的第二个div元素。如果一个行内元素display取值为inline-block那么它就具有了块元素的一些特性,例如我们可以改变它的尺寸。
  • 另一个常用的display值是 none。一些特殊元素的默认display 值是它,例如 scriptdisplay:none 通常被 JavaScript 用来在不删除元素的情况下隐藏或显示元素。它和 visibility 属性不一样。把 display 设置成 none 元素不会占据它本来应该显示的空间,但是设置成 visibility: hidden; 还会占据空间。
  • display 还有一些其他取值,可以看这里

2. 浮动布局

属性:float
取值:
none: 默认,无浮动
left: 左浮动,让元素在父元素的左边,或者挨着已有的浮动元素
right: 右浮动,让元素在父元素的右边,或者挨着已有的浮动元素
我们看下它如何使用,正常情况下的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 <!DOCTYPE html>
<html>
<head>
<title>正常布局</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
background-color: wheat;
}
.left {
width: 100px;
height: 200px;
background-color: rosybrown;
}
.right {
width: 100px;
height: 200px;
background-color: saddlebrown;
}
.last {
width: 100px;
height: 200px;
background-color: red;
}
</style>
</head>

<body>
<div class="container">
<div class="left">左侧</div>
<div class="right">右侧</div>
</div>
<div class="last">后面</div>
</body>
</html>

图片2

现在我们分别给.left.right 添加浮动属性,添加下面代码

1
2
3
4
5
6
7
8
9
10
11
12
.left {
float: left;
width: 100px;
height: 200px;
background-color: rosybrown;
}
.right {
float: right;
width: 100px;
height: 200px;
background-color: saddlebrown;
}

图片3

看下页面效果,左右两个模块倒是符合我们的预期,但是container元素的背景没有了,我们检查下元素,可以看到container的高度变成了0,last只展示了文字,背景没有了。这就说到float属性的特点了:

特点:

  • 元素将被排除在文档流之外,不在占据页面的空间。其他未浮动元素将会向前占位。
  • 浮动元素会 停靠在父元素的 左边或者 右边,或者其他浮动元素的边缘上。
  • 文本行内文字都是采用环绕方式排列的,不会被浮动元素压地下,而是会巧妙的避开浮动元素。
    解决浮动带来的影响
    (1)可以直接设置父元素的高度,比如直接添加height:300px;
    (2)为父元素设置overflow 取值 hidden或者auto
    (3)使用clear属性,取值both可以解决左右浮动带来的影响。这里注意:这里要给contariner容器添加子元素
    (4)在容器内添加一个CSS伪元素,并将其clear属性设置为both。
    1
    2
    3
    4
    5
    <div class="container">
    <div class="left">左侧</div>
    <div class="right">右侧</div>
    <div style="clear:both"></div>
    </div>
    或者是
    1
    2
    3
    4
    5
    .container::after {
    content: "";
    display: table;
    clear: both;
    }
    看下效果,符合我们的预期了
    tupian3

实际上浮动最主要的作用:是可以使块级元素在一行内显示。并且可以使文字可以环绕浮动元素进行排列。

3. 定位布局

  • 属性:position,默认值:static,根据其取值的的不同分为:
    相对定位:position:relative;
    绝对定位:position:absolute;
    固定定位:position:fixed;
    只要是元素的position 属性取值不为static,那么这个元素就被称为已定位元素。这里需要注意,后面在介绍这几种定位时,已定位元素会对他本身的位置有所影响。
  • 偏移属性: topleftrightbottom,取值是以px为单位的数值,取值可以是正数也可以是负数,分别对应不同方向的偏移
    top:取值为正,向下移动,取值为负数,向上移动
    bottom:取值为正,向上移动,负值 向下移动。
    left:取值为正,右移动,负值 左移动
    right:取值为正 左移动,负值 右移动

如何记忆:可以参考下面的图例,取值为正数时,都是向元素的内部进行靠拢,取值为负数时候都是向远离元素的位置移动。
tupian4

(1)相对定位relative

元素相对于他原来的位置进行距离偏移

用法:position:relative;配合 top、left、right、bottom进行位置的微调
我们还是以上面的正常文档流布局为例,现在我们想让右侧块跟左侧块在一行显示,并且在左侧块的右边显示,实现下图的效果
pic5

那么我们可以设置.right的布局如下

1
2
3
4
5
6
7
8
.right {
position: relative;
top: -100px;
left: 50px;
width: 50px;
height: 100px;
background-color: saddlebrown;
}
(2)绝对定位absolute

元素会相对于离他最近的已定位的(三种定位方式的一中)祖先元素去实现位置的初始化跟偏移,祖先元素就是本元素的父级元素。如果没有已定位的祖先元素,那就相对于body去实现位置的初始化跟偏移。绝对定位元素会变成块级元素。

用法:position:absolute;配合 top、left、right、bottom进行位置的微调
现在我们想让右侧块块叠加在左侧块之上显示,实现下图的效果;
pic7
那么我们可以设置.right的布局如下

1
2
3
4
5
6
7
8
.right {
position: absolute;
top: 50px;
left: 0;
width: 50px;
height: 100px;
background-color: saddlebrown;
}
  • 可以看到当子元素元素的postion属性设置为absolute后 原先父元素的高度变化了,说明.right脱离了文档流,不再占据页面空间。注意这里top: 50px;的改变是相对于body改变的,其初始化位置为文档流的左上角,因为其父元素container并不是已定位的元素。
(3)固定定位fixed

固定定位(position:fixed)元素会相对于视窗来定位,这意味着即便页面滚动,它还是会停留在相同的位置。和 relative 一样, top 、 right 、 bottom 和 left 属性都可用。

我们还是拿上面的例子,我们将last固定在页面的右下角,设置last的CSS如下

1
2
3
4
5
6
7
8
.last {
position: fixed;
right: 0;
bottom: 0;
width: 70px;
height: 75px;
background-color: red;
}
  • 注意position:fixed的元素也会脱离文档流。
(4)定位补充

一旦将元素设置 已定位元素(position的取值不为static),元素可能会出现堆叠效果,堆叠效果的属性:z-index,取值:无单位的数子,数子越大越靠上

我们以上面绝对定位的例子来说明,设置了right为绝对定位后,rightleft的上面,此时如果想让left在上,right在下,实现下面效果
pic8

设置left的CSS代码

1
2
3
4
5
6
7
.left {
position: relative;
z-index: 10;
width: 50px;
height: 100px;
background-color: rosybrown;
}
  • 注意:只有已经定位的元素可以使用z-index,如果不设置position: relative;是没有效果的,父子元素之间无法调整堆叠效果,子元素压在父上。

4. flex布局

flex布局又叫弹性布局,是一种为一维布局而设计的布局方法。一维的意思是你希望内容是按行或者列来布局。你可以使用display:flex来将元素变为弹性布局。我们直接看例子:
实现7个item横向排列
图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<!DOCTYPE html>
<html>
<head>
<title>flex布局2</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-end;
background-color: wheat;
flex-wrap: wrap;
}
.item {
width: 100px;
height: 100px;
border: 1px solid royalblue;
text-align: center;
line-height: 100px;
margin: 10px 10px;
}
.item6 {
flex: 1;
}
.item7 {
flex: 2;
}
</style>
</head>

<body>
<div class="container">
<div class="item0 item">item0</div>
<div class="item1 item">item1</div>
<div class="item2 item">item2</div>
<div class="item3 item">item3</div>
<div class="item4 item">item4</div>
<div class="item5 item">item5</div>
<div class="item6 item">item6</div>
<div class="item7 item">item7</div>
</div>
</body>
</html>

上面的代码基本基本涵盖了我们常用的flex的属性

  • display:flex: 设置container容器为弹性布局

  • flex-direction:决定主轴的方向,项目横向或是纵向排列
    取值:row | row-reverse | column | column-reverse;
    row(默认值):主轴为水平方向,起点在左端。
    row-reverse:主轴为水平方向,起点在右端。
    column:主轴为垂直方向,起点在上沿。
    column-reverse:主轴为垂直方向,起点在下沿。

  • justify-content: 定义Item在主轴上如何对齐。
    取值:flex-start | flex-end | center | space-between | space-around;
    flex-start(默认值):左对齐
    flex-end:右对齐
    center: 居中
    space-between:两端对齐,项目之间的间隔都相等。
    space-around:每个项目两侧的间隔相等。所以,项目之间的间隔比项目与边框的间隔大一倍。

  • align-items:定义Item在交叉轴上如何对齐。
    取值:align-items: flex-start | flex-end | center;
    flex-start:交叉轴的起点对齐。
    flex-end:交叉轴的终点对齐。
    center:交叉轴的中间点对齐。

  • flex-wrap:一条轴线上放不下,决定其是否换行
    取值: nowrap(不换行) | wrap(换行)

  • flex 属性:flex属性是flex-grow, flex-shrinkflex-basis的简写,默认值为0 1 auto。
    flex-grow: 属性定义项目的放大比例,默认为0,即如果存在剩余空间,也不放大。 取值越大,占用剩余空间越大。
    flex-shrink: 属性定义了项目的缩小比例,默认为1,即如果空间不足,该项目将缩小。
    flex-basis:属性指定了flex元素在主轴方向上的初始大小

这里设置item6item7 的flex为1和2,表示当前轴剩余的空间item6占1/3,item7占2/3.

我们访问caniuse查看浏览器对它支持情况,可以看到目前绝大多数浏览器是支持这个属性的。
pic9
更多的关于flex的属性可以查看这里:flex布局

5. grid布局

grid布局又叫网格布局,讲到布局,我们就会想到 flex布局,甚至有人认为既然有flex布局了,似乎没有必要去了解Grid布局。但flex布局和Grid布局有实质的区别,那就是flex 布局是一维布局,Grid布局是二维布局。flex布局一次只能处理一个维度上的元素布局,一行或者一列。Grid布局是将容器划分成了“行”和“列”,产生了一个个的网格,我们可以将网格元素放在与这些行和列相关的位置上,从而达到我们布局的目的。看下面的例子
图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!DOCTYPE html>
<html>
<head>
<title>grid2布局</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
display: grid;
grid-template-columns: repeat(4,1fr);
grid-auto-rows: minmax(100px,auto);
grid-gap: 10px;
background-color: skyblue;
}
.item {
border: 1px solid cyan;
text-align: center;
}
.item0{
background-color: slateblue;
grid-row: 1/2;
grid-column: 1/5;
}
.item1 {
background-color: slategray;
grid-row: 2/3;
grid-column: 1;
}
.item2 {
background-color: springgreen;
grid-row: 2/3;
grid-column: 2/3;
}
.item3 {
background-color: teal;
grid-row: 2/3;
grid-column: 3/4;
}
.item4 {
background-color: wheat;
grid-row: 2/4;
grid-column: 4/5;
}
.item5 {
background-color: #BEE7E9;
grid-row: 3/4;
grid-column: 1/3;
}
.item6{
background-color: #E6CEAC;
grid-row: 3/4;
grid-column: 3/4;
}
.item7{
background-color: #ECAD9E;
grid-row: 4/5;
grid-column: 1/5;
}
</style>
</head>
<body>
<div class="container">
<div class="item0 item">item0</div>
<div class="item1 item">item1</div>
<div class="item2 item">item2</div>
<div class="item3 item">item3</div>
<div class="item4 item">item4</div>
<div class="item5 item">item5</div>
<div class="item6 item">item6</div>
<div class="item7 item">item7</div>
</div>
</body>
</html>

上面的代码基本基本涵盖了我们常用的grid的属性

  • display: grid : 设置container容器为网格布局
  • grid-template-columnsgrid-template-rows:分别设置网格的列宽与行高,取值有多中形式例如:
    1
    2
    3
    4
    /* 声明了三列,宽度分别为 200px 100px 200px */
    grid-template-columns: 200px 100px 200px;
    /* 声明了两行,行高分别为 50px 50px */
    grid-template-rows: 50px 50px;
    或者是这样像我们的例子中使用repeat()函数进行赋值,该函数接受两个参数,第一个参数是重复的次数,第二个参数是所要重复的值。我们的例子中grid-template-columns: repeat(4,1fr); 出现了一个新的单位frfr代表网格容器中可用空间的一等份,类似于flex的flex-grow。如果这样取值grid-template-columns: 200px 1fr 2fr 表示第一个列宽设置为 200px,后面剩余的宽度分为两部分,宽度分别为剩余宽度的 1/3 和 2/3。
  • grid-auto-rowsgrid-auto-columns: 表示超出我们没有定义网络的宽度跟高度取值。例如上面的例子grid-template-columns: repeat(4,1fr); 我们只是显示的指定了列的个数,以及宽度,并没有指定行高,那么grid会取grid-auto-rows: minmax(100px,auto);的值来作为行高。minmax()函数产生一个长度范围,表示长度就在这个范围之中都可以应用到网格项目中,它接受两个参数,分别为最小值和最大值。
  • grid-gap: 表示网格间的间隙大小,也可以单独这行跟列的间隙:grid-row-gap 属性、grid-column-gap.
  • grid-rowgrid-column: 表示单个项目的四个边框的起始网格线跟结束网格线,从而确定单个网格的大小跟位置。我们将项目化分为四行四列的网格,那么横向网格线为从1-5,纵向网格线也为从1-5,这里item0的取值grid-row: 1/2;grid-column: 1/5;表示:行高从第一根网格线到第二跟网格线,列宽度:从第一根网格线到第五根网格线。

看下它的浏览器支持情况,总体兼容性还不错,但在IE10以下不支持.
pic10

更多的关于grid的属性可以查看这里:grid布局

三、布局的应用

通过上面的学习我们了解了Web开发常用的几种布局方式,下面我们将这些布局方式来应用到我们的实际开发中,我们就以最常见的几种布局方式来说明

1.双栏目布局实现

双栏布局在我么的Web开发中间经常用到,例如下面的CSDN的内容详情页。主要特点:一侧栏固定,另一侧栏宽度自适应。
pic21
我们使用float+margin的方式去实现,左侧栏浮动,右侧设置左外边距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>
<head>
<title>双栏目布局</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
background-color: white;
}
.left {
float: left;
width: 200px;
height: 200px;
background-color: rosybrown;
}
.right {
margin-left: 210px;
height: 100px;
background-color: saddlebrown;
}
.last {
height: 30px;
background-color: red;
}
</style>
</head>
<body>
<div class="container">
<div class="left">左侧</div>
<div class="right">右侧</div>
<div style="clear:both"></div>
</div>
<div class="last">后面内容</div>
</body>
</html>

实现效果
pic34
注意要给container添加为内容空的子元素<div style="clear:both"></div>,清除浮动带来的影响。

2.三栏目布局实现

三栏布局也是我们经常遇到的布局方式,它的特点主要是:两边定宽,中间自适应。有多种方式实现三栏布局,我们这里介绍其中四种方式
buj

(1)position + margin 实现

实现思路:

  • 设置父元素为相对定位,左右两栏采用绝对定位,分别固定于页面的左右两侧
  • 中间的主体栏用左右margin值撑开距离
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    .box {
    /*父元素设置相对定位,否则左右栏会相对Body元素进行位置的偏移*/
    position: relative;
    background-color:wheat;
    }
    .middle {
    height: 100px;
    margin-left: 110px;
    margin-right: 110px;
    background-color:royalblue;
    }
    .left {
    position: absolute;
    top: 0px;
    left: 0px;
    width: 100px;
    height: 100px;
    background-color:salmon;
    }
    .right {
    position: absolute;
    top: 0;
    right: 0;
    width: 100px;
    height: 100px;
    background-color:sandybrown;
    }
    1
    2
    3
    4
    5
    6
    <div class="box">
    <div class="left">左边</div>
    <div class="middle">中间</div>
    <div class="right">右边</div>
    </div>
    <div>尾部内容</div>
    看下实现效果
    yup
(2)圣杯布局

实现思路:

  • 中间元素占据第一位置优先渲染,设置该元素 width 为 100%
  • 左中右三个元素分别左浮动,并且进行清除浮动带来的影响
  • 左元素设置左边距为-100%以使得左元素上升一行并且处于最左位置,右元素设置左边距为自身宽度的负值使得右元素上升一行处于最右位置。
  • 设置父元素的左右 padding 为左右两个元素留出空间,以展示中间元素内容。
  • 设置左右元素为相对定位,左元素的 left 和右元素的 right 为内边距的宽度的负值。
    代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <body>
    <div class="box">
    <div class="middle">中间</div>
    <div class="left">左边</div>
    <div class="right">右边</div>
    <div style="clear: both;"></div>
    </div>
    <div class="last">后面</div>
    </body>
    CSS
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    body {
    /* 设置body的最小宽度 */
    min-width: 800px;
    }
    .box {
    padding: 0 210px;
    width: 100%;
    background-color:red;
    }
    .middle {
    float: left;
    width: 100%;
    height: 200px;
    background-color:royalblue;
    }
    .left {
    float: left;
    width: 200px;
    height: 200px;
    background-color:salmon;
    /*左边距为-100%以使得左元素上升一行并且处于最左位置*/
    margin-left: -100%;
    /*相对定位*/
    position: relative;
    left: -210px;
    }
    .right {
    float: left;
    width: 200px;
    height: 200px;
    background-color:sandybrown;
    /*元素设置左边距为自身宽度的负值使得右元素上升一行处于最右位置*/
    margin-left: -200px;
    /*相对定位*/
    position: relative;
    right: -210px;
    }
    .last {
    background-color:thistle;
    height: 40px;
    }
    效果
    pic2
(3)双飞翼布局

实现思路:

  • 中间采用嵌套子元素方法,宽度自适应
  • 左中右三个元素分别左浮动,并且进行清除浮动带来的影响
  • 左元素设置左边距为-100%以使得左元素上升一行并且处于最左位置,右元素设置左边距为自身宽度的负值使得右元素上升一行处于最右位置。
  • 设置中间元素的子元素左右边距为左右元素留空位,以展示中间元素内容。
    代码实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <body>
    <div class="container">
    <div class="middle">中间</div>
    </div>
    <div class="left">左侧</div>
    <div class="right">右侧</div>
    <div style="clear: both;"></div>
    <div class="last">尾部</div>
    </body>
    CSS
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    body {
    min-width: 800px;
    }
    .container{
    float: left;
    /* 整个容器大小 */
    width: 100%;
    height: 200px;
    background: #ddd;
    }
    .container .middle{
    height: 200px;
    /* 设置左右边距 */
    margin: 0 160px;
    background-color:slateblue;
    }
    .left{
    float: left;
    width: 150px;
    height: 200px;
    background-color: rosybrown;
    margin-left: -100%;
    }
    .right{
    float: left;
    width: 150px;
    height: 200px;
    background-color:saddlebrown;
    margin-left: -150px;
    }
    .last {
    background-color: tomato;
    }
    效果
    pic

圣杯布局跟双飞翼布局的实现上,在前部分是一样的。同样都是左右栏定宽,中间栏自适应。采用浮动和负边距使左右栏与中间栏并排。不同之处大部分在于中间元素的的展示方式上。圣杯布局采用父元素设置内边距的方法,左右元素设置相对定位辅助。而双飞翼布局在中间采用嵌套子元素方法,通过设置子元素外边距来展示。

(4)flex布局实现

对比前面两中实现直接实用flex布局能够比较容易的实现三栏布局

1
2
3
4
5
<div class="container">
<div class="left">左侧</div>
<div class="middle">中间</div>
<div class="right">右侧</div>
</div>

CCS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
background-color: wheat;
/* height: 100vh; */
}
.left {
width: 150px;
height: 200px;
background-color: rosybrown;
}
.right {
width: 150px;
height: 200px;
background-color: saddlebrown;
}
.middle {
/* 宽度沾满剩下的整个区域 */
flex: 1;
height: 200px;
background-color: seagreen;
}

pic

3.响应式布局

响应式布局是Ethan Marcotte在2010年5月份提出的一个概念,简而言之,就是一个网站能够兼容多个终端——而不是为每个终端做一个特定的版本。这个概念是为解决移动互联网浏览而诞生的。

saumang
例如三星网站的设计,当我们改变浏览器大小时,页面的布局会发生相应的改变,那么这些变化是如何实现的呢?我们介绍三种实现方式

(1)媒体查询CSS3-Media Query

使用 @media查询,可以针对不同的媒体类型定义不同的样式。当你重置浏览器大小的过程中,页面也会根据浏览器的宽度和高度重新渲染页面。
语法

1
2
3
@media mediatype and|not|only (media feature) {
CSS-Code;
}
  • mediatype: 媒体类型, 例如:screen:计算机屏幕(默认值),tv:电视类型设备 等
  • and|not|only :逻辑操作符
    and:用来把多个媒体属性组合起来,合并到同一条媒体查询中
    not:用来对一条媒体查询的结果进行取反
    only:表示仅在媒体查询匹配成功时应用指定样式
  • media feature: 多数媒体属性,带有“min-”和“max-”前缀,用于表达“大于等于”和“小于等于”。例如width | min-width | max-width,height | min-height | max-height,aspect-ratio | min-aspect-ratio | max-aspect-ratio

适配进行响应式开发时往往需要针对不同的屏幕添加多个断点,看个例子
toppic

1
2
3
4
5
6
7
8
9
10
<div class="container">
<div class="item item0">item0</div>
<div class="item item1">item1</div>
<div class="item item2">item2</div>
<div class="item item3">item3</div>
<div class="item item4">item4</div>
<div class="item item5">item5</div>
<div class="item item6">item6</div>
<div class="item item7">item7</div>
</div>

CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: white;
}
/* 当屏幕尺寸大于等于1025 样式 */
@media only screen and (min-width: 1025px){
body {
background-color: wheat;
}
.item {
float: left;
border: 1px solid skyblue;
width: 300px;
height: 50px;
line-height: 50px;
text-align: center;
margin: 10px 30px;
}
}
/** iPad 屏幕尺寸大于等于768 小于等于1024 竖屏**/
@media only screen and (min-width: 768px) and (max-width: 1024px) {
body {
background-color: rosybrown;
}
.item {
float: left;
border: 1px solid skyblue;
width: 200px;
height: 50px;
line-height: 50px;
text-align: center;
margin: 5px 20px;
}
}
/** iPhone 屏幕尺寸小于等于767 竖屏样式**/
@media only screen and (max-width: 767px){
body {
background-color:royalblue;
}
.item {
float: left;
border: 1px solid skyblue;
width: 100px;
height: 30px;
line-height: 30px;
text-align: center;
margin: 5px 10px;
}
}
</style>
  • <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">这句代码的作用是禁止用户进行手动的放大缩小页面
  • 设置了三个断点,根据不同的屏幕宽度展示不同的样式。
  • 还需要注意@media 不支持IE9及以下的浏览器,如果要支持需要添加额外的代码
    1
    2
    3
    4
    <!--[if lt IE 9]>
    <script src="https://cdn.staticfile.org/html5shiv/r29/html5.min.js"></script>
    <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
    <![endif]-->
(2)grid实现响应式布局

第二种方式主要是利用grid的特性来实现响应布局。假如有这样的需求:我们不希望Item过小,需要有最小宽度限制,并且当视口宽度增加时我们不希望看到右侧有空白区域的出现。实现下面效果
pic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!doctype html>
<html>
<head>
<title>响应式布局2</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background-color: white;
}
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 10px 20px;
grid-auto-rows: 50px;
}
.item {
border: 1px solid skyblue;
text-align: center;
margin: 10px 30px;
height: 50px;
line-height: 50px;
}
</style>
</head>
<body>
<div class="container">
<div class="item item0">item0</div>
<div class="item item1">item1</div>
<div class="item item2">item2</div>
<div class="item item3">item3</div>
<div class="item item4">item4</div>
<div class="item item5">item5</div>
<div class="item item6">item6</div>
<div class="item item7">item7</div>
</div>
</body>
</html>
  • 关于grid的属性我们再上面已经介绍过了这里不再赘述。唯一没有介绍的是auto-fit关键字,repeat()函数的作用是重复创建多个Item,第一个参数数个数,第二个是大小,这里取值auto-fit的意思是数量是自适应的,只要容纳得下,就会往上排列。
(3)Columns栅格系统

Columns栅格系统往往需要依赖某个UI库,如Bootstrip或者是Element UI等。

  • Bootstrap来自Twitter,是一个用于快速开发 Web 应用程序和网站的前端框架。它提供了一套响应式、移动设备优先的流式网格系统,随着屏幕或视口(viewport)尺寸的改变,系统会自动将视口分为最多12列。它根据当前视口的大小添加多个媒体查询断点,可以让开发者方便的根据视口大小调整每个网格所占整体视口的宽度。
  • Bootstrap(5.0)有六种默认响应尺寸:xssmmdlgxlxxl,对应关系如下
    duandian

它的基本的网格结构

1
2
3
4
5
6
7
<div class="container">
<div class="row">
<div class="col-*-*"></div>
<div class="col-*-*"></div>
</div>
<div class="row">...</div>
</div>

使用规则

  • 行必须放置在 .containerclass 内,以便获得适当的对齐(alignment)和内边距(padding)
  • 使用行来创建列的水平组,内容应该放置在列内,且唯有列可以是行的直接子元素
  • 预定义的网格类,比如 .row 和 .col-xs-4,可用于快速创建网格布局。
  • 网格系统是通过指定您想要横跨的十二个可用的列来创建的。例如,要创建三个相等的列,则使用三个 .col-xs-4。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!doctype html>
<html lang="zh-CN">
<head>
<!-- 必须的 meta 标签 -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bootstrap</title>
<!-- Bootstrap 的 CSS 文件 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<style>
.item {
height: 50px;
}
</style>
</head>
<body>
<div class="container">
<div class="row g-1">
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3 item bg-light border">item0</div>
<div class="col-xs-12 col-sm-6 col-md-4 col-lg-3 item bg-light border">item0</div>
<div class="col-xs-12 col-sm-12 col-md-4 col-lg-3 item bg-light border">item0</div>
<div class="col-xs-12 col-sm-12 col-md-12 col-lg-3 item bg-light border">item0</div>
</div>
</div>
</body>
</html>

效果
pic
其他官方示例,Element UI的响应式布局与Bootstrap是相似的工作原理,这里不再赘述。
想了解更多关于BootstrapElement UI相关内容点击他们

四、总结

作为前端小白,学习完Html、CSS、JavaScript等一堆东西,等到真正开始写Web页面还是一头雾水,回忆刚学习写客户端时肯定要从基础的页面布局来入手,于是有了这篇文章。因此这篇文章主要面向的是前端初学者,还请大神勿喷。当然由于作者水平有限,难免出现纰漏,如有问题还请不吝赐教。文章涉及源码
参考资料:
Bootstrap
Element UI
CSS布局
Grid 布局完全指南

iOS-架构浅谈 从 MVC、MVP 到 MVVM

概述

做了这么多年的客户端研发一直在使用苹果爸爸推荐的MVC架构模式。MVC从应用层面进行分层开发,极大优化了我们的代码结构,简单易上手,很容易被程序员所接受。程序员刚接手一个新项目,如果是MVC的架构模式,会减少代码熟悉时间,快速的进行开发和维护工作,实际上对于多人开发维护的项目,MVC仍然是非常好的架构模式,这也是这种架构模式经久不衰的原因。
但是任何事物都有两面性,随着项目需求的增加,业务逻辑、网络请求、代理方法等都往Controller层加塞,导致Controller层变得越来越臃肿,动辄上千行的代码量绝对是维护人员的噩梦,因此在MVC基础上逐渐衍生出来了MVP、MVVM等架构模式。

本文是基于OC代码进行阐述的,使用iOS开发经典的 TableView 列表来分析每个架构模式。相信看了这篇文章你会有所领悟。当然一千个人眼中有一千种哈姆雷特,具体在业务开发中使用哪种模式需要你自己去衡量。

1.传统的MVC设计模式

MVC

M: Model 数据层,负责网络数据的处理,数据持久化存储和读取等工作

V: View 视图层,负责呈现从数据层传递的数据渲染工作,以及与用户的交互工作

C: Controller控制器,负责连接Model层跟View层,响应View的事件和作为View的代理,以及界面跳转和生命周期的处理等任务

用户的交互逻辑

用户点击 View(视图) –> 视图响应事件 –>通过代理传递事件到Controller–>发起网络请求更新Model—>Model处理完数据–>代理或通知给Controller–>改变视图样式–>完成

可以看到Controller强引用View与Model,而View与Model是分离的,所以就可以保证Model和View的可测试性和复用性,但是Controller不行,因为Controller是Model和View的中介,所以不能复用,或者说很难复用。

iOS开发实际使用的MVC架构

实际MVC
在我们实际开发中使用的MVC模式可以看到,View与Controller耦合在一起了。这是由于每一个界面的创建都需要一个Controller,而每一个Controller里面必然会带一个View,这就导致了C和V的耦合。这种结构确实可以提高开发效率,但是一旦界面建复杂就会造成Controller变得非常臃肿和难以维护。

MVC代码示例

我们要实现一个简单的列表页面,每行cell都一个按钮,点击按钮前面数字➕1操作。

mvcexamp
核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Controller
- (UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

__weak typeof(self) wealSelf = self;
MVCTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell_identifer"];
if(cell == nil){
cell = [[MVCTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell_identifer"];
}
DemoModel *model = self.dataArray[indexPath.row];
[cell loadDataWithModel:model];
cell.clickBtn = ^{
NSLog(@"id===%ld",model.num);
[wealSelf changeNumWithModel:model];
};
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
/*
* 用户点击事件通过Block传递过来后,在Controller层处理更新Mdoel以及更新视图的逻辑
*/
- (void)changeNumWithModel:(DemoModel*)model{

model.num++;
NSIndexPath *path = [NSIndexPath indexPathForRow:model.Id inSection:0];
[self.mainTabelView reloadRowsAtIndexPaths:@[path] withRowAnimation:UITableViewRowAnimationLeft];
}

  • 可以看到用户点击事件通过Block传递过来后,在Controller层处理更新Mdoel以及更新视图的逻辑

2.MVP设计模式

MVP
M: Model 数据层,负责网络数据的处理,数据持久化存储和读取等工作

V: View 视图层,负责呈现从数据层传递的数据渲染工作,以及与用户的交互,这里把Controller层也合并到视图层

P: Presenter层,负责视图需要数据的获取,获取到数据后刷新视图。响应View的事件和作为View的代理。

可以看到 MVP模式跟原始的MVC模式非常相似,完全实现了View与Model层的分离,而且把业务逻辑放在了Presenter层中,视图需要的所有数据都从Presenter获取,而View与 Presenter通过协议进行事件的传递。

用户的交互逻辑

用户点击 View(视图) –> 视图响应事件 –>通过代理传递事件到Presenter–>发起网络请求更新Model–>Model处理完数据–>代理或通知给视图(View或是Controller)–>改变视图样式–>完成

MVP代码示例

项目结构

1
2
3
4
5
6
7
8
9
10
//DemoProtocal
import <Foundation/Foundation.h>

@protocol DemoProtocal <NSObject>
@optional
//用户点击按钮 触发事件: UI改变传值到model数据改变 UI --- > Model 点击cell 按钮
-(void)didClickCellAddBtnWithIndexPathRow:(NSInteger)index;
//model数据改变传值到UI界面刷新 Model --- > UI
-(void)reloadUI;
@end
  • 我们把所有的代理抽象出来,成为一个Protocal文件。这两个方法的作用:
  • -(void)didClickCellAddBtnWithIndexPathRow:(NSInteger)index;:Cell视图调用它去Presenter层实现点击逻辑的处理
  • -(void)reloadUI;: Presenter调用它去更新主视图View或者Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Presenter.h
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import "DemoProtocal.h"

NS_ASSUME_NONNULL_BEGIN

@interface Presenter : NSObject
@property (nonatomic, strong,readonly) NSMutableArray *dataArray;
@property (nonatomic, weak) id<DemoProtocal>delegate;//协议,去更新主视图UI
// 更新 TableView UI 根据需求
- (void)requestDataAndUpdateUI;
//更新 cell UI
- (void)updateCell:(UITableViewCell*)cell withIndex:(NSInteger)index;
@end
  • dataArray : 视图需要的数据源
  • - (void)requestDataAndUpdateUI;:主视图Controller调用,去更新自己的UI
  • - (void)updateCell:(UITableViewCell*)cell withIndex:(NSInteger)index;:更新 Cell的UI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//Controller 层
- (void)iniData{
self.presenter = [[Presenter alloc] init];
self.presenter.delegate = self;
[self.presenter requestDataAndUpdateUI];
}
...

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return self.presenter.dataArray.count;
}
- (UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

MVPTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell_identifer"];
if(cell == nil){
cell = [[MVPTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell_identifer"];
}
//更新cell UI 数据
[self.presenter updateCell:cell withIndex:indexPath.row];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}

#pragma mark - DemoProtocal
//Presenter 的代理回调 数据更新了通知View去更新视图
- (void)reloadUI{
[self.mainTabelView reloadData];
}
  • Controller层初始化Presenter,调用其方法更新自己的UI,可以看到网络数据的获取,处理都在Presenter中,处理完成后通过协议回调给Controller去reload数据
1
2
3
4
5
6
7
//Cell
- (void)addBtnDown:(UIButton*)btn{
NSLog(@"%s",__func__);
if([self.delegate respondsToSelector:@selector(didClickCellAddBtnWithIndexPathRow:)]){
[self.delegate didClickCellAddBtnWithIndexPathRow:self.index];
}
}
  • Cell层点击事件通过协议调用,而这个协议方法的实现是在Presenter中实现的。

MVP模式也有自身的缺点,所有的用户操作和更新UI的回调需要定义,随着交互越来越复杂,这些定义都要有很大一坨代码。逻辑过于复杂的情况下,Present本身也会变得臃肿。所以衍生出了MVVM模式。

3.MVVM+RAC设计模式

MVVM

M: Model 数据层,负责网络数据的处理,数据持久化存储和读取等工作

V: View 视图层,此时的视图层包括Controller,负责呈现从数据层传递的数据渲染工作,以及与用户的交互

VM:ViewModel层,负责视图需要数据的获取,获取到数据后刷新视图。响应View的事件和作为View的代理等工作。

通过架构图可以看到,MVVM模式跟MVP模式基本类似。主要区别是在MVP基础上加入了双向绑定机制。当被绑定对象某个值的变化时,绑定对象会自动感知,无需被绑定对象主动通知绑定对象。可以使用KVO和RAC实现。我们这里采用了RAC的实现方式。关于RAC如果不熟悉的小伙伴可以点这里,我们这篇文章不在涉及。

MVVM代码示例

项目结构)

我们这里包括两层视图:主视图Controller以及Cell,分别对应两层ViewModel:ViewModel和CellViewModel

1
2
3
4
5
6
7
8
//ViewModel.h

@interface ViewModel : NSObject
//发送数据请求的Rac,可以去订阅获取 请求结果
@property (nonatomic,strong,readonly) RACCommand *requestCommand;
@property (nonatomic,strong) NSArray *dataArr;//返回子级对象的ViewModel
- (CellViewModel *)itemViewModelForIndex:(NSInteger)index;
@end
  • RACCommand *requestCommand:提供供主视图调用的命令,调用它去获取网络数据
  • NSArray *dataArr: 提供供主视图使用的数据源,注意这里不能用NSMutableArray,因为NSMutableArray不支持KVO,不能被RACObserve。
  • - (CellViewModel *)itemViewModelForIndex:(NSInteger)index; 根据Cell的index返回它需要的的ViewModel
1
2
3
4
5
6
7
8
9
10
11
@interface CellViewModel : NSObject

@property (nonatomic,copy,readonly) NSString *titleStr;

@property (nonatomic,copy,readonly) NSString *numStr;

@property (nonatomic,copy,readonly) RACCommand *addCommand;

- (instancetype)initWithModel:(DemoModel *)model;

@end
  • CellViewModel: 暴露出Cell渲染需要的所有数据
  • RACCommand *addCommand;: 按钮点击事件的指令,触发后需要在CellViewModel里面做处理。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    //controller
    - (void)iniData{
    self.viewModel = [[ViewModel alloc] init];
    // 发送请求
    RACSignal *signal = [self.viewModel.requestCommand execute:@{@"page":@"1"}];
    [signal subscribeNext:^(id x) {
    NSLog(@"x=======%@",x);
    if([x boolValue] == 1){//请求成功
    [self.mainTabelView reloadData];
    }
    }];
    }
    - (UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{

    MVVMTableVIewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell_identifer"];
    if(cell == nil){
    cell = [[MVVMTableVIewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"Cell_identifer"];
    }
    //更新cell UI 数据
    cell.cellViewModel = [self.viewModel itemViewModelForIndex:indexPath.row];
    cell.selectionStyle = UITableViewCellSelectionStyleNone;

    return cell;
    }
  • iniData:初始化ViewModel,并发送请求命令。这里可以监听这个完成信号,进行刷新视图操作
  • cell.cellViewModel = [self.viewModel itemViewModelForIndex:indexPath.row]; 根据主视图的ViewModel去获取Cell的ViewModel,实现cell的数据绑定。
1
2
3
4
5
6
7
8
9
//TableViewCell

RAC(self.titleLabel,text) = RACObserve(self, cellViewModel.titleStr);
RAC(self.numLabel,text) = RACObserve(self, cellViewModel.numStr);

[[self.addBtn rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(id x) {
NSLog(@">>>>>");
[self.cellViewModel.addCommand execute:nil];
}];
  • 在Cell里面进行与ViewModel的数据绑定,这边有个注意Racobserve左边只有self右边才有viewModel.titleStr这样就避Cell重用的问题。
  • [self.cellViewModel.addCommand execute:nil];:按钮的点击方法触发,事件的处理在CellViewModel中。

总结

  • 经过几十年的发展和演变MVC模式出现了各种各样的变种,并在不同的平台上有着自己的实现。在实际项目开发中,根据具体的业务需求找到适合的架构才是最好的,架构本身并没有好坏之分。
  • 最后对文中的MVC、MVP、MVVM架构的描述也掺杂了作者的主观意见,如果对文中的内容有疑问,欢迎提出不同的意见进行讨论。
  • 本文的Demo已上传作者GitHub

iOS-滤镜那些事儿

一. GPUImage 框架的介绍及基本使用

1.GPUImage 的介绍

GPUImage是基于OpenGL ES的一套图像、视频处理开源框架,它里面提供了大量的滤镜,使用者可以通过这些滤镜的组合实现很好的效果,同时也很方便在原有基础上实现自定义的滤镜。对于大规模并行操作(如处理图像或实时视频帧),GPU具有比CPU更显着的性能优势。而 GPUImage 所有滤镜是基于OpenGL Shader实现的,所以滤镜效果、图像处理是在GPU上执行的,处理效率比较高,在iPhone4及其以上手机,可以做到实时流畅的效果。而且它隐藏了Objective-COpenGL ES API交互的复杂性。目前市面上的图像视频处理App,95%以上在使用GPUImage,所以学习它的使用及原理还是很有必要的。GPUImage 同时支持iOS跟Andorid平台,地址:iOS版本 Android版本 也支持 Swift版本,本文主要介绍它的 OC 版本,核心类的功能以及原理跟 Andorid 版本是相通的。
iOS开发者使用方式:直接 CocaPods 集成:

1
pod 'GPUImage'

首先来看下它的基本结构图:架构图

从这张图中我们可以看到GPUImage的几个核心类:GPUImageOutput GPUImageFilter GPUImageInput 协议 GPUImageFrameBuffer,接下来我们重点讲解这几个类。

2.核心功能类说明

GPUImageOutput

GPUImageOutput 是所有滤镜输入源的基类,也就是滤镜链的起点,先看下他的继承关系:

GPUImageOutput

分别解释一下这几种类型:

  • GPUImagePicture
    通过图片来初始化,本质上是先将图片转化为 CGImageRef,然后将 CGImageRef 转化为纹理。
  • GPUImageVideoCamera:通过相机来初始化,本质是封装了AVCaptureVideoDataOutput来获取持续的视频流数据输出,在代理方法captureOutput:didOutputSampleBuffer:fromConnection:拿到 CMSampleBufferRef,将其转化为纹理的过程。GPUImageStillCamera是 GPUImageVideoCamera 的子类,可以用它来实现拍照功能。
  • GPUImageUIElement:可以通过 UIView 或者 CALayer 来初始化。这个类可以用来实现在视频上添加文字水印的功能。
  • GPUImageTextureInput:通过已经存在的纹理来初始化.
  • GPUImageRawDataInput:通过二进制数据初始化,然后将二进制数据转化为纹理.
  • GPUImageMovie:通过本地的视频来初始化。首先通过 AVAssetReader 来逐帧读取视频,然后将帧数据转化为纹理。
  • GPUImageFilter:比较特殊,它既继承自 GPUImageOutput,又遵守协议 GPUImageInput 协议,所以它既可以作为滤镜链的源头,又可以把渲染的纹理输出给遵守 GPUImageInput 协议的类。是滤镜的核心,后面会单独介绍。
核心功能与方法:

想象一下,一个滤镜链的源头能做什么呢:

  1. 需要产出一个渲染对象,这个需要渲染的对象就是GPUImageFrameBuffer.几个关于frameBuffer的方法:
1
- (GPUImageFramebuffer *)framebufferForOutput;

这个方法可以获得当前正在渲染的frameBuffer

1
- (void)removeOutputFramebuffer;

这个方法用来移除当前渲染的frameBuffer

1
- (void)setInputFramebufferForTarget:(id<GPUImageInput>)target atIndex:(NSInteger)inputTextureIndex;

这个方法的调用发生在当前output渲染完毕后,需要通知下一个receiver可以开始渲染的时候,把当前Output的FrameBuffer传递给下一个Input,让它可以使用这个FrameBuffer的结果进行渲染。

  1. Target的添加以及管理,用来生成整个FilterChain.

    GPUImageOutput 既然作为一个滤镜的源头,相对应的就得有接受者接受它输出的 FrameBuffer ,这些接受者就是Target,而且有可能有多个接受者。管理这些target的主要方法:
    1
    2
    - (void)addTarget:(id<GPUImageInput>)newTarget;
    - (void)addTarget:(id<GPUImageInput>)newTarget atTextureLocation:(NSInteger)textureLocation;
    这两个addTarget方法的作用都是将下一个实现了GPUImageInput协议的对象添加到FilterChain当中来.一旦添加到滤镜链后,在当前Output渲染完成后就会收到通知,从而进行下一步的处理。
1
- (NSArray*)targets;

每个Output都可以添加多个target,这个方法可以获取到当前Output所有的target.

1
2
- (void)removeTarget:(id<GPUImageInput>)targetToRemove;
- (void)removeAllTargets;

这两个方法的作用是将某一个或者所有的target都移出FilterChain。当一个target被移出FilterChain之后,它将不会再收到任何当前Output渲染完成的通知。

  1. 获取当前的GPUImageOutput对FrameBuffer的处理结果
    1
    2
    3
    4
    5
    6
    - (CGImageRef)newCGImageFromCurrentlyProcessedOutput;
    - (CGImageRef)newCGImageByFilteringCGImage:(CGImageRef)imageToFilter;
    - (UIImage *)imageFromCurrentFramebuffer;
    - (UIImage *)imageFromCurrentFramebufferWithOrientation:(UIImageOrientation)imageOrientation;
    - (UIImage *)imageByFilteringImage:(UIImage *)imageToFilter;
    - (CGImageRef)newCGImageByFilteringImage:(UIImage *)imageToFilter;
    其中最核心的方法是newCGImageFromCurrentlyProcessedOutput,基本上所有的方法最终都调用了这个方法。但是GPUImageOutput并没有为这个方法提供默认的实现,而是提供了一个方法定义。具体的实现在它的两个重要的子类 GPUImageFilter 和 GPUImageFilterGroup 中。而实际上最终调用的方法都在 GPUImageFilter 中实现了.
GPUImageInput协议

GPUImageInput 是一个协议,它定义了一个能够接收 FrameBuffer 的 receiver 所必须实现的基本功能。实现这个协议的类可以作为渲染的终点使用。
实现了 GPUImageInput 接口的类:

GPUImageInput协议
对这几个类进行解释:

  • GPUImageMovieWriter:封装了 AVAssetWriter,可以逐帧从帧缓存的渲染结果中读取数据,最后通过 AVAssetWriter 将视频文件保存到指定的路径。
  • GPUImageView:继承自 UIView,通过输入的纹理,执行一遍渲染流程。我们一般使用它来呈现渲染结果。
  • GPUImageTextureOutput:它可以获取到输入的Framebuffer中的纹理对象.
  • GPUImageRawDataOutput:通过 rawBytesForImage 属性,可以获取到当前输入纹理的二进制数据。
核心功能与方法:

可以作为滤镜链的终点。基本功能主要包括:

  • 接收 GPUmageOutput 的输出信息;
  • 接收上一个GPUImageOutput渲染完成的通知,并且完成自己的处理;
  1. 接收GPUmageOutput的输出信息对应方法:
    1
    2
    3
    4
    - (void)setInputFramebuffer:(GPUImageFramebuffer *)newInputFramebuffer atIndex:(NSInteger)textureIndex;
    - (NSInteger)nextAvailableTextureIndex;
    - (void)setInputSize:(CGSize)newSize atIndex:(NSInteger)textureIndex;
    - (void)setInputRotation:(GPUImageRotationMode)newInputRotation atIndex:(NSInteger)textureIndex;
    根据这些方法可以看到,GPUImageInput 可以接收的信息包括上一个Output输出的FrameBuffer,FrameBuffer的size以及rotation。这些 textureIndex 都是为了提供个需要多个input的Filter准备的。
  2. 接收GPUImageOutput渲染完成的通知对应方法:
    1
    - (void)newFrameReadyAtTime:(CMTime)frameTime atIndex:(NSInteger)textureIndex;
    上一个 GPUImageOutput 渲染完成后会通知它所有的 Target,可以参考下它在GPUImageFilter里面的实现。
GPUImageFrameBuffer

GPUImageFrameBuffer 提供了在 GPUImageOutput 和 GPUImageInput 进行数据传递的媒介。在整个渲染流程中,GPUImageFrameBuffer作为一个纽带,将各个不同的元素串联起来;每个GPUImageFrameBuffer 都有一个自己的OpenGL Texture,每个 GPUImageOutput 都会输出一个 GPUImageFrameBuffer 对象,而每个 GPUImageInput都实现了一个setInputFramebuffer:atIndex:方法,来接收上一个Output处理完的纹理.

  • GPUImageFrameBuffer 的获取逻辑,是由GPUImageFrameBufferCache 进行管理的,需要时从BufferCache中获取,使用完成后,被BufferCache回收。FrameBuffer 的创建跟存储是需要消耗资源的,所以 GPUImage 为了尽量减少资源的消耗,会将使用完成的 FrameBuffer 存储在缓存中,每次通过 输入的纹理size 跟 TextureOptions 作为 key 从hash map 中获取。
GPUImageFilter

GPUImageFilter 是整个GPUImage框架的核心,GPUImage所内置的100多种滤镜效果都继承于此类。例如我们经常用到的一些滤镜:

  • GPUImageBrightnessFilter:亮度调整滤镜
  • GPUImageExposureFilter:曝光调整滤镜
  • GPUImageContrastFilter:对比度调整滤镜
  • GPUImageSaturationFilter:饱和度调整滤镜
  • GPUImageWhiteBalanceFilter:白平衡调整滤镜
  • GPUImageColorInvertFilter:反转图像的颜色
  • GPUImageCropFilter:将图像裁剪到特定区域
  • GPUImageGaussianBlurFilter:可变半径高斯模糊
  • GPUImageSketchFilter:素描滤镜
  • GPUImageToonFilter:卡通效果
  • GPUImageDissolveBlendFilter:两个图像的混合
  • GPUImageFilterPipeline : 链式组合滤镜
核心功能与方法:
  1. GPUImageFilter是GPUImageOutput的子类,但是同时它也实现了GPUImageInput协议。因此,它包含了一个Input和Output的所有功能。既它可以接受一个待渲染对象,渲染完成后继续传递给下一个实现GPUImageInput协议的接受者。具体的方法调用我们在下一小节的 滤镜底层源码分析中讲解。

  2. 提供根据不同的顶点着色器(VertexShader)与片元着色器(FragmentShader)来初始化渲染程序(GLProgram)的方法,但是整个渲染过程是一样的,因此这个过程都被封装到了基类中;

    1
    2
    3
    - (id)initWithVertexShaderFromString:(NSString *)vertexShaderString fragmentShaderFromString:(NSString *)fragmentShaderString;
    - (id)initWithFragmentShaderFromString:(NSString *)fragmentShaderString;
    - (id)initWithFragmentShaderFromFile:(NSString *)fragmentShaderFilename;

    这里简单介绍一下这几个OPenGL的术语

  • VertexShader:顶点着色器,OPenGL 接收用户传递的几何数据(顶点信息和几何图元),这些数据经过顶点着色器后可以确定图形的形状以及位置。顶点着色器是 OPenGL 渲染过程的第一个着色器。
  • 光栅化:是将图形的立体位置转换成在屏幕上显示的像素片元的过程;
  • FragmentShader:对光栅化的像素点进行着色就要使用片元着色器。它是OPenGL渲染过程的最后一个着色器。
  • GLProgram: OpenGL ES的program的面向对象封装,包括了VertexShader,FragmentShader的加载,program的link以及对attribute和uniform的获取和管理.
    这里主要是一些根据不同的着色器进行创建Program的方法。
  1. 作为基类提供给子类可以进行覆盖的方法。

用一句话来总结GPUImageFilter的作用:就是用来接收源图像(FrameBuffer),通过自定义的顶点、片元着色器来渲染新的图像,并在绘制完成后通知响应链的下一个对象。

3.GPUImage滤镜的使用

我们先来看它的应用效果

效果效果2

(1) 为图片添加滤镜

直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**初始化滤镜源头*/
GPUImagePicture *imagePic = [[GPUImagePicture alloc] initWithImage:[UIImage imageNamed:@"picOne.jpg"]];
/**创建滤镜*/
GPUImageGaussianBlurFilter *gaussianBlur = [[GPUImageGaussianBlurFilter alloc] init];
gaussianBlur.blurRadiusInPixels = 10;
/**添加接受者,即target*/
[imagePic addTarget:gaussianBlur];
/**增加frameBUffer 计数防止被移除*/
[gaussianBlur useNextFrameForImageCapture];
/**开始处理图片*/
[imagePic processImage];
/**根据frameBuffer 获取图片*/
self.showImageView.image = [gaussianBlur imageFromCurrentFramebuffer];
流程说明:
  • 使用图片初始化滤镜源头GPUImagePicture
  • 初始化滤镜效果GPUImageGaussianBlurFilter
  • 为当前滤镜源添加接收者Target addTarget
  • useNextFrameForImageCapture:方法是防止帧缓存被移除,如果不调用这个方法会导致Framebuffer被移除,从而导致Crash
  • 根据滤镜的渲染结果FrameBuffer导出图片[gaussianBlur imageFromCurrentFramebuffer]
(2) 摄像头捕获视频流添加滤镜

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (void)setupCamera
{
//videoCamera
self.gpuVideoCamera = [[GPUImageVideoCamera alloc] initWithSessionPreset:AVCaptureSessionPreset640x480 cameraPosition:AVCaptureDevicePositionBack];
self.gpuVideoCamera.outputImageOrientation = [[UIApplication sharedApplication] statusBarOrientation];
//GPUImageView填充模式
self.gpuImageView.fillMode = kGPUImageFillModePreserveAspectRatioAndFill;
//空白效果
GPUImageFilter *clearFilter = [[GPUImageFilter alloc] init];
[self.gpuVideoCamera addTarget:clearFilter];
[clearFilter addTarget:self.gpuImageView];
//Start camera capturing, 里面封装的是AVFoundation的session的startRunning
[self.gpuVideoCamera startCameraCapture];
}
#pragma mark - Action && Notification
- (IBAction)originalBtnDown:(id)sender {
/**先移除target*/
[self.gpuVideoCamera removeAllTargets];
//空白效果
GPUImageFilter *clearFilter = [[GPUImageFilter alloc] init];
[self.gpuVideoCamera addTarget:clearFilter];
[clearFilter addTarget:self.gpuImageView];
}
(3) 混合滤镜的使用

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
GPUImageView *filterView = [[GPUImageView alloc] initWithFrame:self.view.frame];
filterView.center = self.view.center;
filterView.fillMode = kGPUImageFillModePreserveAspectRatioAndFill;
[self.view addSubview:filterView];
/*初始化混合滤镜*/
filter = [[GPUImageDissolveBlendFilter alloc] init];
/*设置滤镜混合度*/
[(GPUImageDissolveBlendFilter *)filter setMix:0.5];
/*初始化视频输出源*/
NSURL *sampleURL = [[NSBundle mainBundle] URLForResource:@"IMG_4278" withExtension:@"MOV"];
movieFile = [[GPUImageMovie alloc] initWithURL:sampleURL];
movieFile.runBenchmark = YES;
movieFile.playAtActualSpeed = YES;
/*初始化摄像头输出源*/
videoCamera = [[GPUImageVideoCamera alloc] initWithSessionPreset:AVCaptureSessionPreset640x480 cameraPosition:AVCaptureDevicePositionBack];
videoCamera.outputImageOrientation = UIInterfaceOrientationPortrait;
NSString *pathToMovie = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/Movie.m4v"];
unlink([pathToMovie UTF8String]);
NSURL *movieURL = [NSURL fileURLWithPath:pathToMovie];
//初始化接受者
movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:movieURL size:CGSizeMake(480.0, 640.0)];
GPUImageFilter* progressFilter = [[GPUImageFilter alloc] init];
[movieFile addTarget:progressFilter];
//设置输出方向
[progressFilter setInputRotation:kGPUImageRotateRight atIndex:0];
// 响应链
[progressFilter addTarget:filter];
[videoCamera addTarget:filter];
//设置音源
movieWriter.shouldPassthroughAudio = YES;
movieFile.audioEncodingTarget = movieWriter;
[movieFile enableSynchronizedEncodingUsingMovieWriter:movieWriter];
// 显示到界面
[filter addTarget:filterView];
//添加到接收者
[filter addTarget:movieWriter];
[videoCamera startCameraCapture];
[movieWriter startRecording];
[movieFile startProcessing];
/*写入结束后保存视频*/
__weak typeof(self) weakSelf = self;
[movieWriter setCompletionBlock:^{
__strong typeof(self) strongSelf = weakSelf;
[strongSelf->filter removeTarget:strongSelf->movieWriter];
[strongSelf->movieWriter finishRecording];
/*根据movieURL保存视频到本地*/
// ...
}];

流程说明:
  • 混合滤的核心是GPUImageDissolveBlendFilter的使用,它继承自GPUImageTwoInputFilter,它需要有两个输入源
  • 初始化两个输入源GPUImageVideoCameraGPUImageMovie
  • 添加输入源到DissolveBlendFilter
  • 添加filter到输出数据源GPUImageMovieWriter
(4) 为视频添加水印

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
GPUImageView *filterView = [[GPUImageView alloc] initWithFrame:self.view.frame];
self.view = filterView;
// 混合滤镜初始化
filter = [[GPUImageDissolveBlendFilter alloc] init];
//混合度
[(GPUImageDissolveBlendFilter *)filter setMix:0.5];
// 本地视频播放源
NSURL *sampleURL = [[NSBundle mainBundle] URLForResource:@"IMG_4278" withExtension:@"MOV"];
AVAsset *asset = [AVAsset assetWithURL:sampleURL];
CGSize size = self.view.bounds.size;
//设置moive源头
movieFile = [[GPUImageMovie alloc] initWithAsset:asset];
movieFile.runBenchmark = YES;
movieFile.playAtActualSpeed = YES;
// 水印
UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
label.text = @"我是水印";
label.font = [UIFont systemFontOfSize:30];
label.textColor = [UIColor redColor];
[label sizeToFit];
UIImage *image = [UIImage imageNamed:@"watermark.png"];
UIImageView *imageView = [[UIImageView alloc] initWithImage:image];
UIView *subView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, size.width, size.height)];
subView.backgroundColor = [UIColor clearColor];
imageView.center = CGPointMake(subView.bounds.size.width / 2, subView.bounds.size.height / 2);
[subView addSubview:imageView];
[subView addSubview:label];
//设置UI源头
GPUImageUIElement *uielement = [[GPUImageUIElement alloc] initWithView:subView];
//GPUImageTransformFilter 动画的filter
NSString *pathToMovie = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/Movie.m4v"];
unlink([pathToMovie UTF8String]);
NSURL *movieURL = [NSURL fileURLWithPath:pathToMovie];
//初始化接受者
movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:movieURL size:CGSizeMake(480.0, 640.0)];
//为调整视频方向添加一个空白滤镜
GPUImageFilter* progressFilter = [[GPUImageFilter alloc] init];
[movieFile addTarget:progressFilter];
//设置方向
[progressFilter setInputRotation:kGPUImageRotateRight atIndex:0];

[progressFilter addTarget:filter];
[uielement addTarget:filter];
movieWriter.shouldPassthroughAudio = YES;
movieFile.audioEncodingTarget = movieWriter;
[movieFile enableSynchronizedEncodingUsingMovieWriter:movieWriter];
// 显示到界面
[filter addTarget:filterView];
[filter addTarget:movieWriter];
//开始记录
[movieWriter startRecording];
[movieFile startProcessing];
__weak typeof(self) weakSelf = self;
//每一帧处理完成 大约30帧/秒
[progressFilter setFrameProcessingCompletionBlock:^(GPUImageOutput *output, CMTime time){
CGRect frame = imageView.frame;
frame.origin.x += 1;
frame.origin.y += 1;
imageView.frame = frame;
//更新UIElement
[uielement updateWithTimestamp:time];
}];
[movieWriter setCompletionBlock:^{
__strong typeof(self) strongSelf = weakSelf;
[strongSelf->filter removeTarget:strongSelf->movieWriter];
[strongSelf->movieWriter finishRecording];
/*根据movieURL保存视频到本地*/
// ...
}];
流程说明:
  • 混合滤镜的核心是GPUImageDissolveBlendFilter的使用,它继承自GPUImageTwoInputFilter,它需要有两个输入源
  • 初始化两个输入源GPUImageVideoCameraGPUImageUIElement
  • 其他同上
(5) 滤镜组的使用

核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//创建摄像头视图
GPUImageView *filterView = [[GPUImageView alloc]initWithFrame:self.view.bounds];
//显示模式充满整个边框
filterView.fillMode = kGPUImageFillModePreserveAspectRatioAndFill;
[self.view addSubview:filterView];
//初始化滤镜源
self.stillCamera = [[GPUImageStillCamera alloc]initWithSessionPreset:AVCaptureSessionPresetPhoto cameraPosition:AVCaptureDevicePositionBack];
//输出图像旋转方式
self.stillCamera.outputImageOrientation = UIInterfaceOrientationPortrait;
//反色滤镜
GPUImageColorInvertFilter *filter1 = [[GPUImageColorInvertFilter alloc]init];
//浮雕滤镜
GPUImageEmbossFilter *filter2 = [[GPUImageEmbossFilter alloc]init];
//GPUImageToonFilter *filter3 = [[GPUImageToonFilter alloc] init];
GPUImageFilterGroup *groupFilter = [[GPUImageFilterGroup alloc]init];
[groupFilter addFilter:filter1];
[groupFilter addFilter:filter2];
//[groupFilter addFilter:filter3];
[filter1 addTarget:filter2];
//[filter2 addTarget:filter3];
//定义了一个变量来保存filter-chain上的最后一个filter,后面保存图片时调用的方法里要用到。
self.lastFilter = filter2;
//设置第一个滤镜
groupFilter.initialFilters = @[filter1];
//设置最后一个滤镜
groupFilter.terminalFilter = filter2;
[self.stillCamera addTarget:groupFilter];
[groupFilter addTarget:filterView];
//解决第一帧黑屏,音频缓冲区是在视频缓冲区之前写入的。
[self.stillCamera addAudioInputsAndOutputs];
[self.view bringSubviewToFront:self.catchBtn];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
//开始捕捉
[self.stillCamera startCameraCapture];
});

流程说明:
  • 混合滤的核心是GPUImageFilterGroup的使用
  • 初始化多个滤镜并且添加到滤镜组
  • 设置Group的第一个以及最后一个滤镜
  • 输出

二. GPUImage 底层源码分析

1.滤镜链加载流程分析

通过上面的Demo例子我们能够分析滤镜链的使用流程:

GPUImageFilter流

接下来我们以图片添加滤镜的例子分析GPUImage的滤镜方法调用流程:

  • 使用图片初始化滤镜源头GPUImagePicture,调用方法:
    1
    - (id)initWithImage:(UIImage *)newImageSource;
    这个方法里面又会调用
    1
    outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:pixelSizeToUseForTexture onlyTexture:YES];
    这个方法最主要的作用是根据图片的大小去GPUImageFramebufferCache中去获取一块 FrameBuffer,也就是outputFramebuffer
  • 滤镜的初始化,根据当前自己的顶点着色器以及片元着色器初始化滤镜,以及创建OPenGL ES的渲染程序 GLProgram
  • 为滤镜源添加Target:- (void)addTarget:(id<GPUImageInput>)newTarget;. 在这个方法里面会调用
    [self setInputFramebufferForTarget:newTarget atIndex:textureLocation];
    最终会调用[target setInputFramebuffer:[self framebufferForOutput] atIndex:inputTextureIndex];方法.这个方法最主要的作用是把当前Output的输出 Framebuffer 传递给接受者.
  • - (void)useNextFrameForImageCapture;设置成员变量usingNextFrameForImageCapture = YES代表着输出的结果会被用于获取图像,所以在渲染的核心方法
    1
    - (void)renderToTextureWithVertices:(const GLfloat *)vertices textureCoordinates:(const GLfloat *)textureCoordinates;
    outputFramebuffer加锁,因为默认情况下,当下一个input渲染完成之后,就会释放这个 FrameBuffer。如果你需要对当前的Filter的输出进行截图的话,则需要保留住这个 FrameBuffer。
  • 接下来调用方法[imagePic processImage];: 开始进入滤镜处理流程,接着调用方法-(BOOL)processImageWithCompletionHandler:(void (^)(void))completion;在这个方法内部调用了Target的两个方法,进行OutputFrameBuffer的渲染与向下传递.
1
2
[currentTarget setInputFramebuffer:outputFramebuffer atIndex:textureIndexOfTarget];
[currentTarget newFrameReadyAtTime:kCMTimeIndefinite atIndex:textureIndexOfTarget];

第一个方法的作用是获取从上个Output传递过来的 Framebuffer,并进行加锁操作。

第二个方法的作用是利用自身GLProgram进行渲染,并且调用- (void)informTargetsAboutNewFrameAtTime:(CMTime)frameTime;把渲染结果向下一个实现GPUImageInput协议的滤镜传递。

  • [gaussianBlur imageFromCurrentFramebuffer]; 方法:根据 Framebuffer 获取图片,里面调用- (CGImageRef)newCGImageFromCurrentlyProcessedOutput 方法,完成图片获取以及释放GCD信号量。
    1
    2
    3
    4
    if (dispatch_semaphore_wait(imageCaptureSemaphore, convertedTimeout) != 0)
    {
    return NULL;
    }
    这里信号量的作用是等待渲染完成。完成后走下面的获取图片流程。整个的方法调用流程可以参考下面的图片:

方法调用栈

2.滤镜渲染流程分析

渲染是整个GPUImageFilter 的核心,在初始化方法中完成了OpenGL ES Program的创建好并且link成功了之后,我们就可以使用这个Program进行渲染了。整个渲染的过程发生在- (void)renderToTextureWithVertices:textureCoordinates:中。我们也借着解析这个方法来了解一下OpenGL ES的渲染过程:

  • [GPUImageContext setActiveShaderProgram:filterProgram];: 将初始化后得到Progrm 上下文设置为默认的context,并且激活。调用的GPUImageContext方法
    1
    2
    3
    4
    5
    + (void)setActiveShaderProgram:(GLProgram *)shaderProgram;
    {
    GPUImageContext *sharedContext = [GPUImageContext sharedImageProcessingContext];
    [sharedContext setContextShaderProgram:shaderProgram];
    }
  • 获取一个待渲染的GPUImageFrameBuffer,这个FrameBuffer 会根据输入纹理的尺寸(inputTextureSize)以及纹理信息(outputTextureOptions) 去GPUImageFrameBufferCahe中获取。大致流程为:存在符合要求的Framebuffer就返回一个,没有就去创建。
  • 根据usingNextFrameForImageCapture判断当前Framebuffer是否用于获取图片,如果是则进行加锁。
    1
    2
    3
    4
    if (usingNextFrameForImageCapture)
    { //将这个outputFrameBuffer进行lock。
    [outputFramebuffer lock];
    }
  • 将整个FrameBuffer的数据使用backgroundColor进行清空:
    1
    2
    glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha);
    glClear(GL_COLOR_BUFFER_BIT);
  • 将上一个Output传递过来的FrameBuffer作为texture用来渲染:
    1
    2
    3
    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]);
    glUniform1i(filterInputTextureUniform, 2);
  • 将顶点的位置信息以及顶点的纹理坐标信息作为attribute传递给GPU:
    1
    2
    glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, vertices);
    glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates);
  • 进行渲染:
    1
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
  • 最后将上一个GPUImageOutput传递过来的FrameBuffer使命已经完成,对其进行解锁释放:
    1
    [firstInputFramebuffer unlock];
    整个渲染过程完成。

三. 自定义滤镜

1.如何加载一个自定义滤镜

通过上面的学习我们知道,滤镜的效果实际是根据不同的顶点着色器以及片元着色器来实现的。是定义滤镜实际就是自定义这两种着色器。有两种方式来加载我们的自定义滤镜

  • 自定义滤镜类,继承自GPUImageFilter,然后用字符串常量形式加载我们的Shader代码例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    NSString *const kGPUImageBrightnessFragmentShaderString = SHADER_STRING
    (
    varying highp vec2 textureCoordinate;
    uniform sampler2D inputImageTexture;
    uniform lowp float brightness;

    void main()
    {
    lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
    gl_FragColor = vec4((textureColor.rgb + vec3(brightness)), textureColor.w);
    }
    );
    然后根据GPUImageFilter提供的初始化方法进行加载。
    1
    2
    3
    - (id)initWithVertexShaderFromString:(NSString *)vertexShaderString fragmentShaderFromString:(NSString *)fragmentShaderString;
    - (id)initWithFragmentShaderFromString:(NSString *)fragmentShaderString;
    - (id)initWithFragmentShaderFromFile:(NSString *)fragmentShaderFilename;
  • 另一种方式:如果只是自定义FragmentShader,可以是将Shader语句封装为fsh结尾的文件,然后调用下面方法进行加载
    1
    - (id)initWithFragmentShaderFromFile:(NSString *)fragmentShaderFilename;

2. 一些特殊的自定义滤镜效果

自定义滤镜

一些特殊的滤镜效果,比如抖音的滤镜效果(闪白、灵魂出窍、抖动、缩放、毛刺、眩晕等)可以查看我的GitHub.
关于自定义滤镜部分需要你对OPenGL ES、线性代数以及算法有基础的了解,并且熟悉GLSL着色语言,如果想进一步学习可以参考GLSL的官方快速入门指导OpenGL ES,我们这篇文章不在涉及。

四. 总结

这篇文章主要是介绍了GPUImage的使用、滤镜链加载流程、渲染逻辑,还有一些模块未涉及到,比如GLProgram的创建、link过程,GPUImageMovieComposition视频编辑模块,滤镜的自定义流程等,需要感兴趣的同学自己探究。

1.进一步学习需要掌握的内容

The OpenGL Shading Language

GLSL内建的函数介绍

2.一些参考引用

https://github.com/BradLarson/GPUImage

https://www.khronos.org/opengles/sdk/docs/reference_cards/OpenGL-ES-2_0-Reference-card.pdf

https://www.jianshu.com/u/8367278ff6cf