信厚的独立博客 My CSDN A current student of The University of HongKong, major in computer science.

My Projects|我的项目

OnGoing Projects | 正在进行研究学习项目

1. Ultrasonic gesture recognition | 超声波手势识别人机交互

这里写图片描述

HMI based on Ultrasonic gesture recognition.
利用手机等移动设备的扩音器发出超声波并使用麦克风收回,利用多普勒效应获取手势造成的波形改变进行识别,采用快速傅里叶变换等快速解析波形对固定设计的几种手势进行识别并应用到手机应用中。

Development Tool: Xcode, C++

2.Vufoia AR | 高通增强现实游戏开发

AR Game development with Vuforia on Unity3d Game Engine. The first demo is to achieve the goal to recognize my Computer keyboard by the camera and renderring a 3d model on the keyboard and interact with players.
使用高通的Vuforia框架在Unity3d游戏引擎中进行AR增强现实游戏的开发。

Development Tool: Unity3D, Vuforia, C#

这里写图片描述
Github Source code


2.OpenGl Step by Step | OpenGl翻译教程工程源码

According to the series tutorials ‘OpenGL Step by Step’, I built a OpenGL Demo project for test, and also can use as a foundamental OpenGL project. Many popular plugins and libs are involved.
‘OpenGL Step by Step’系列教程下的OpenGLDemo个人VS2013测试工程,用于OpenGL的基础工程和测试工程,集成了常用的插件和库。个人翻译版本点击上图跳转到博客专栏

Development Tool: VisualStudio2013, C++

这里写图片描述
Github Source code


UnderGraduate Projects | 本科阶段个人项目

1.SpaceWar Game based on Cocos2d-x | Cocos2d-x机战游戏

这里写图片描述

Design a new zero game with Cocos2d-x, using iPhone’s multi - point touch screen to achieve the function of single user controlling several aircrafts and aiming at activate the thinking and innovative user experience.
使用用iPhone的多点触控技术实现一个玩家控制多架飞机的机战游戏,使用C++语言和cocos2d-x游戏引擎以及各种图像素材处理软件实现游戏的基本功能、特效。

Development Tool: Xcode6,Cocos2d-x 2.1.1, C++

这里写图片描述
Github Source code


2.StickHero based on Unity3D | Unity开发的‘棍子英雄小游戏’

这里写图片描述

Develop the ever popular minigame ‘stickhero’ with Unity2D, and implement the algorithm to play the game with romote controller on TV.
开发实现曾经比较火的小游戏’棍子英雄’,并设计实现电视游戏移植算法,通过这个算法可以实现用遥控器上下左右键搜索换选按钮操作,将游戏移植到电视上。

Development Tool: Xcode6,Cocos2d-x 2.1.1, C++

这里写图片描述
Github Source code


3.Path Finding & VR in Unity | Unity游戏寻路实现与暴风VR开发’

这里写图片描述

Developed a game demo,including NGUI and UGUI,VR game development,Path finding, comunication between Unity and IOS.
本科毕设项目,实现论文中的几个核心模块:Unity中NGUI和UGUI实现3d游戏UI界面,Unity与原生IOS通信(打开一个原生UIWebView,用于SDK的接入),暴风魔镜VR游戏开发,3d游戏中游戏寻路的实现测试。【录视频的时候暴风手柄不在身边,控制VR人物行走和控制游戏寻路暂时无法展示,只能在原地…】

Development Tool: Xcode6,Cocos2d-x 2.1.1, C++

这里写图片描述
Unity源码不小心丢失了,只剩了当时打出来的Xcode工程… …


4.GameLogin&Pay SDK | 游戏统一登录和支付SDK接口

这里写图片描述

This is a typical SDK Demo, providing interface function for login,register,binding etc. and also used for payment by channels like AliPay,WeichatPay,ApplePay and so on.
这里写了一个用于游戏登录的统一的SDK,提供接口函数用于登录,注册以及绑定等等,还提供支付接口,用于实现支付宝支付,微信支付以及apple支付等等,可以很方便快速的接入游戏项目中。

Development Tool: Xcode7, Objective-C

这里写图片描述
无源码分享,归公司所有 | Source code belongs to Xiaoxi Tech.


PostGradute Projects | 研究生阶段项目

1.UNDP SDG Memory Game | 联合国海洋可持续发展会议主题记忆游戏

为2017年联合国海洋可持续发展会议开发主题记忆游戏,多线程编程,社交分享SDK接入,应用上架。

Development Tool: Xcode 8, Objective-C

这里写图片描述
Github Source code


2.FourInaRowGame(Android) | 安卓四子棋小游戏

这里写图片描述

Develop the FourInRow Game with Android foundamental API and adopt the game ‘Plant and Zoombies’ theme and characters.
港大读研期间的平时作业,使用安卓的基础API开发四子棋游戏,游戏的界面采用的植物大战僵尸的主题,通过PS设计实现。

Development Tool: Android Studio2.1.2, Java

这里写图片描述
Github Source code


3.RGB Picture Compression | RGB图像压缩解压缩

这里写图片描述

After implementing lossey compression and uncompression with 555 & 565, I proposed a improved algorithm to achieve lossless compression with better performence based on channel repeat.
首先实现了555和565有损量化压缩和解压。之后根据像素游离算法压缩重复像素的思想,改进后设计实现了压缩RGB不同通道的算法,即分RGB三个通道,将每个通道连续重复的值进行压缩,大大提高了基于像素重复压缩的压缩率,并分析了算法的效果适应情况。

Development Tool: Visual Studio 2013, C++

这里写图片描述
Github Source code


4.Planet War with AI | Cocos2dx 3.x单机AI版‘小行星大作战’

Developing ‘Battle of Balls’ of AI console game version with new features using cocos2d-x 3.x game engine.
使用最新版Coco2dx引擎,开发单机AI版的“小行星大作战”手游,开拓新玩法,游戏AI设计等,加入吞并与射击元素。

Development Tool: Xcode8.0, Cocos2dx 3.1.1, C++

这里写图片描述
Github Source code


5.Visualization with D3.js | 使用D3开发的可视化项目

这里写图片描述

Visualization with D3.js.
使用D3.js设计开发了动态组合图表和文字云等,可视化了过去十年外国游客到中国旅游的不同方面的数据。

Development Tool: Sublime Text, JavaScript(HTML5,CSS), D3.js, JQuery

这里写图片描述
Github Source code


神经网络、遗传算法及其衍生算法

神经网络算法系列

基本感知神经元

BP神经网络(有监督)

SOFM

RFA

Hopfield

遗传算法与集群算法系列

GA

EOA

集群算法

【iOS沉思录】GCD实现线程同步的方法

在iOS多线程中我们知道NSOperationQueue操作队列可以直接使用addDependency函数设置操作之间的依赖关系实现线程同步,还可以使用setMaxConcurrentOperationCount函数直接设置最大并发数量。那么在GCD中又是如何实现线程同步和控制最大并发数量的呢?

事实上在之前的问题中我们已经提到了GCD实现线程同步的两种方法了,一种是组队列(dispatch_group_t),另一种是dispatch_barrier_(a)sync,都是等待前面的任务完成后再执行某个任务。除此之外另外一种实现线程同步的方法是信号量机制。

GCD实现线程同步的方法:

组队列(dispatch_group):

举一个例子:用户下载一个图片,图片很大,需要分成很多份进行下载,使用GCD应该如何实现?使用什么队列?

使用Dispatch Group追加block到Global Group Queue,这些block如果全部执行完毕,就会执行通过dispatch_group_notify添加到主队列中的block,进行图片的合并处理。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, queue, ^{ /*加载图片1 */ });
dispatch_group_async(group, queue, ^{ /*加载图片2 */ });
dispatch_group_async(group, queue, ^{ /*加载图片3 */ }); 
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 合并图片… …
});

阻塞任务(dispatch_barrier):

通过dispatch_barrier_async添加的操作会暂时阻塞当前队列,即等待前面的并发操作都完成后执行该阻塞操作,待其完成后后面的并发操作才可继续。可以将其比喻为一根霸道的独木桥,是并发队列中的一个并发障碍点,或者说中间瓶颈,临时阻塞并独占。注意dispatch_barrier_async只有在并发队列中才能起作用,在串行队列中队列本身就是独木桥,将失去其意义。

可见使用dispatch_barrier_async可以实现类似dispatch_group_t组调度的效果,同时主要的作用是避免数据竞争,高效访问数据。

这里写图片描述

/* 创建并发队列 */
dispatch_queue_t concurrentQueue = dispatch_queue_create("test.concurrent.queue", DISPATCH_QUEUE_CONCURRENT);
/* 添加两个并发操作A和B,即A和B会并发执行 */
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationA");
});
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationB");
});
/* 添加barrier障碍操作,会等待前面的并发操作结束,并暂时阻塞后面的并发操作直到其完成 */
dispatch_barrier_async(concurrentQueue, ^(){
    NSLog(@"OperationBarrier!");
});
/* 继续添加并发操作C和D,要等待barrier障碍操作结束才能开始 */
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationC");
});
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationD");
});
2017-04-04 12:25:02.344 SingleView[12818:3694480] OperationB
2017-04-04 12:25:02.344 SingleView[12818:3694482] OperationA
2017-04-04 12:25:02.345 SingleView[12818:3694482] OperationBarrier!
2017-04-04 12:25:02.345 SingleView[12818:3694482] OperationD
2017-04-04 12:25:02.345 SingleView[12818:3694480] OperationC

信号量机制(dispatch_semaphore):

信号量机制主要是通过设置有限的资源数量来控制线程的最大并发数量以及阻塞线程实现线程同步等。

GCD中使用信号量需要用到三个函数:

  • dispatch_semaphore_create用来创建一个semaphore信号量并设置初始信号量的值;
  • dispatch_semaphore_signal发送一个信号让信号量增加1(对应PV操作的V操作);
  • dispatch_semaphore_wait等待信号使信号量减1(对应PV操作的P操作);

那么如何通过信号量来实现线程同步呢?下面介绍使用GCD信号量来实现任务间的依赖和最大并发任务数量的控制。

使用信号量实现任务2依赖于任务1,即任务2要等待任务1结束才开始执行:

方法很简单,创建信号量并初始化为0,让任务2执行前等待信号,实现对任务2的阻塞。然后在任务1完成后再发送信号,从而任务2获得信号开始执行。需要注意的是这里任务1和2都是异步提交的,如果没有信号量的阻塞,任务2是不会等待任务1的,实际上这里使用信号量实现了两个任务的同步。

/* 创建一个信号量 */
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);

/* 任务1 */
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /* 耗时任务1 */
    NSLog(@"任务1开始");
    [NSThread sleepForTimeInterval:3];
    NSLog(@"任务1结束");
    /* 任务1结束,发送信号告诉任务2可以开始了 */
    dispatch_semaphore_signal(semaphore);
});

/* 任务2 */
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /* 等待任务1结束获得信号量, 无限等待 */
    dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    /* 如果获得信号量则开始任务2 */
    NSLog(@"任务2开始");
    [NSThread sleepForTimeInterval:3];
    NSLog(@"任务2结束");
});
[NSThread sleepForTimeInterval:10];

通过打印的时间可以看到任务2是在任务1结束后紧接着执行的:

2017-06-02 21:21:37.777156+0800 OC[6869:324518] 任务1开始
2017-06-02 21:21:40.782648+0800 OC[6869:324518] 任务1结束
2017-06-02 21:21:40.782829+0800 OC[6869:324519] 任务2开始
2017-06-02 21:21:43.788198+0800 OC[6869:324519] 任务2结束
通过信号量控制最大并发数量:

通过信号量控制最大并发数量的方法为:创建信号量并初始化信号量为想要控制的最大并发数量,例如想要保证最大并发数为5,则信号量初始化为5。然后在每个新任务执行前进行P操作,等待信号使信号量减1;每个任务结束后进行V操作,发送信号使信号量加1。这样即可保证信号量始终在5以内,当前最多也只有5个以内的任务在并发执行。

/* 创建一个信号量并初始化为5 */
dispatch_semaphore_t semaphore = dispatch_semaphore_create(5);

/* 模拟1000个等待执行的任务,通过信号量控制最大并发任务数量为5 */
for (int i = 0; i < 1000; i++) {
    /* 任务i */
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        /* 耗时任务1,执行前等待信号使信号量减1 */
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        NSLog(@"任务%d开始", i);
        [NSThread sleepForTimeInterval:10];
        NSLog(@"任务%d结束", i);
        /* 任务i结束,发送信号释放一个资源 */
        dispatch_semaphore_signal(semaphore);
    });
}
[NSThread sleepForTimeInterval:1000];

打印结果为每次开启五个并发任务
2017-06-02 21:45:27.409067+0800 OC[7234:336757] 任务1开始
2017-06-02 21:45:27.409069+0800 OC[7234:336758] 任务2开始
2017-06-02 21:45:27.409103+0800 OC[7234:336759] 任务3开始
2017-06-02 21:45:27.409268+0800 OC[7234:336761] 任务4开始
2017-06-02 21:45:27.409887+0800 OC[7234:336756] 任务0开始

2017-06-02 21:45:37.415217+0800 OC[7234:336757] 任务1结束
2017-06-02 21:45:37.415370+0800 OC[7234:336759] 任务3结束
2017-06-02 21:45:37.415217+0800 OC[7234:336761] 任务4结束
2017-06-02 21:45:37.415217+0800 OC[7234:336758] 任务2结束
2017-06-02 21:45:37.415442+0800 OC[7234:336756] 任务0结束

2017-06-02 21:45:37.415544+0800 OC[7234:336760] 任务5开始
2017-06-02 21:45:37.415548+0800 OC[7234:336762] 任务6开始
2017-06-02 21:45:37.415614+0800 OC[7234:336765] 任务9开始
2017-06-02 21:45:37.415620+0800 OC[7234:336764] 任务8开始
2017-06-02 21:45:37.415594+0800 OC[7234:336763] 任务7开始

... ...

【iOS沉思录】BAD ACCESS错误调试

BAD_ACCESS 在什么情况下出现

BAD_ACCESS 报错属于内存访问错误,会导致程序崩溃,错误的原因是访问了野指针(悬挂指针)。野指针指的是本来指针指向的对象已经释放了,但指向该对象的指针没有置 nil,指针指向随机的未知的内存,程序还以为该指针指向那个对象,导致存在一些潜在的危险访问操作,这些危险访问操作无法被指针指向的未知内存所处理,就会导致BAD_ACCESS错误造成程序崩溃。访问的含义包括多种情况,例如:向野指针发送消息,读写野指针本来指向的对象的成员变量等等。

如何调试BAD_ACCESS错误

首先调试BAD_ACCESS错误是比较困难的,我们知道BAD_ACCESS错误是由于访问了野指针,但程序不会在野指针出现时或者在我们访问野指针的代码处报错,导致对其难以察觉,调试方法思路如下:

  • 开启僵尸对象诊断

首先是开启僵尸对象诊断模式,利用僵尸对象来对野指针的出现位置提供线索。我们知道僵尸对象指的是引用计数为0被系统回收的对象,但这些对象暂时还存在于内存中,且理论上还是可以使用的,但是不稳定。开启僵尸对象诊断后,僵尸对象会暂时保持活跃用于调试,我们的野指针在对象回收后依然指向该僵尸对象,在访问野指针也就是访问僵尸对象的情况下可以被编辑器检测出来。这个时候还是会报BAD_ACCESS错误,但是后台会打印出该线索,例如下面的访问野指针打印的后台信息:

2017-03-12 16:28:31.501 Debug[2371:1379247] -[TestViewController respondsToSelector:] 
message sent to deallocated instance 0x16749682

可以看出Xcode告诉我们消息发送给了一个僵尸对象,僵尸对象原本是TestViewController的一个实例,但现在该对象被回收了而开发者还试图访问它,由此可以很容易定位问题所在。

另外开启僵尸对象诊断的方法为:打开Xcode顶部导航栏的Product-Scheme-Edit Scheme,在弹出的界面中选中左侧的Run模式,然后勾选右侧Dianostics下的Zombie Objects。不同版本Xcode可能选项位置略有差异,此处为最新的Xcode 8.2.1。

这里写图片描述

这里写图片描述

这里写图片描述

  • Analyze分析

僵尸对象诊断可以帮助快速定位多数情况下的野指针问题,但也有时候不能奏效,这个时候只能利用Xcode的Analyze静态分析帮助检查可能出问题的地方,仔细检查问题所在,比较费时。

使用方法很简单,选中Xcode顶部导航栏Product-Analyze或使用快捷键Command+Shift+B,分析需要花一些时间,然后左侧会列出编辑器发现的存在潜在问题的地方,选中蓝色图标对应的问题项会跳到问题项所在的代码行。但这只能给出一些潜在提示,帮助搜索问题所在,不一定和我们的bug相关。

这里写图片描述

这里写图片描述

【iOS沉思录】UIImage圆角矩形的‘离屏渲染’和‘在屏渲染’实现方法

iOS中为view添加圆角效果有两种方式,一种基于“离屏渲染”(off-screen-renderring),直接设置view的layer层参数即可简单实现,也很常用,但性能较低;另一种则是编写底层图形代码,实现‘在屏渲染’(on-screen-renderring),可以大大优化绘制性能。

iOS中圆角效果实现的最简单、最直接的方式,是直接修改View的layer层参数:

/* 设置圆角半径 */
view.layer.cornerRadius = 5;
/* 将边界以外的区域遮盖住 */
view.layer.masksToBounds = YES;

这种方法最简单快速,但其实这种方法的实现是靠的‘离屏渲染’(off-screen-rendering),性能很低。

另外一种则是实现on-screen-rendering,用于提高性能。

为UIImage类扩展一个实例函数:

/**
 * On-screen-renderring绘制UIImage矩形圆角
 */
- (UIImage *)imageWithCornerRadius:(CGFloat)radius ofSize:(CGSize)size{
    /* 当前UIImage的可见绘制区域 */
    CGRect rect = (CGRect){0.f,0.f,size};
    /* 创建基于位图的上下文 */
    UIGraphicsBeginImageContextWithOptions(size, NO, UIScreen.mainScreen.scale);
    /* 在当前位图上下文添加圆角绘制路径 */
    CGContextAddPath(UIGraphicsGetCurrentContext(), [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius].CGPath);
    /* 当前绘制路径和原绘制路径相交得到最终裁剪绘制路径 */
    CGContextClip(UIGraphicsGetCurrentContext());
    /* 绘制 */
    [self drawInRect:rect];
    /* 取得裁剪后的image */
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    /* 关闭当前位图上下文 */
    UIGraphicsEndImageContext();
    return image;
}

使用时,让实例化的UIImage对象调用一下上面的实例方法即可:

UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(10, 10, 100, 100)];
/* 创建并初始化UIImage */
UIImage *image = [UIImage imageNamed:@"icon"];
/* 添加圆角矩形 */
image = [image imageWithCornerRadius:50 ofSize:imageView.frame.size];
[imageView setImage:image];

完整代码

这里写图片描述

UIImage类别扩展在屏渲染实例函数:

//
//  UIImage+RadiusCorner.h
//  SingleView
//
//  Created by Xinhou Jiang on 19/4/17.
//  Copyright © 2017年 Xinhou Jiang. All rights reserved.
//

#import <UIKit/UIKit.h>

@interface UIImage (RadiusCorner)

/* On-screen-renderring绘制UIImage矩形圆角 */
- (UIImage *)imageWithCornerRadius:(CGFloat)radius ofSize:(CGSize)size;

@end
//
//  UIImage+RadiusCorner.m
//  SingleView
//
//  Created by Xinhou Jiang on 19/4/17.
//  Copyright © 2017年 Xinhou Jiang. All rights reserved.
//

#import "UIImage+RadiusCorner.h"

@implementation UIImage (RadiusCorner)

/**
 * On-screen-renderring绘制UIImage矩形圆角
 */
- (UIImage *)imageWithCornerRadius:(CGFloat)radius ofSize:(CGSize)size{
    /* 当前UIImage的可见绘制区域 */
    CGRect rect = (CGRect){0.f,0.f,size};
    /* 创建基于位图的上下文 */
    UIGraphicsBeginImageContextWithOptions(size, NO, UIScreen.mainScreen.scale);
    /* 在当前位图上下文添加圆角绘制路径 */
    CGContextAddPath(UIGraphicsGetCurrentContext(), [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius].CGPath);
    /* 当前绘制路径和原绘制路径相交得到最终裁剪绘制路径 */
    CGContextClip(UIGraphicsGetCurrentContext());
    /* 绘制 */
    [self drawInRect:rect];
    /* 取得裁剪后的image */
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    /* 关闭当前位图上下文 */
    UIGraphicsEndImageContext();
    return image;
}

@end

测试代码:

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(50, 50, 100, 100)];
    UIImage *image = [UIImage imageNamed:@"icon"];
    
    /* 1. on-screen-renderring */
    //image = [image imageWithCornerRadius:50 ofSize:imageView.frame.size];
    
    [imageView setImage:image];
    
    /* 2. off-screen-renderring */
    imageView.layer.cornerRadius = 20;
    imageView.layer.masksToBounds = YES;
    
    [self.view addSubview:imageView];
}

@end

【iOS沉思录】OC消息传递机制三道防线:消息转发机制详解

消息传递机制:

在OC中,方法的调用不再理解为对象调用其方法,而是要理解成对象接收消息,消息的发送采用‘动态绑定’机制,具体会调用哪个方法直到运行时才能确定,确定后才会去执行绑定的代码。方法的调用实际就是告诉对象要干什么,给对象(的指针)传送一个消息,对象为接收者(receiver),调用的方法及其参数即消息(message),给一个对象传消息表达为:[receiver message]; 接受者的类型可以通过动态类型识别于运行时确定。

在消息传递机制中,当开发者编写[receiver message];语句发送消息后,编译器都会将其转换成对应的一条objc_msgSend C语言消息发送原语,具体格式为: void objc_msgSend (id self, SEL cmd, ...)

这个原语函数参数可变,第一个参数填入消息的接受者,第二个参数是消息‘选择子’,后面跟着可选的消息的参数。有了这些参数,objc_msgSend就可以通过接受者的的isa指针,到其类对象中的方法列表中以选择子的名称为‘键’寻找对应的方法,找到则转到其实现代码执行,找不到则继续根据继承关系从父类中寻找,如果到了根类还是无法找到对应的方法,说明该接受者对象无法响应该消息,则会触发‘消息转发机制’,给开发者最后一次挽救程序崩溃的机会。

消息转发机制:

如果消息传递过程中,接受者无法响应收到的消息,则会触发进入‘消息转发’机制。

消息转发依次提供了三道防线,任何一个起作用都可以挽救此次消息转发。按照先后顺序三道防线依次为:

  • 动态补加方法的实现
+ (BOOL)resolveInstanceMethod:(SEL)sel
+ (BOOL)resolveClassMethod:(SEL)sel
  • 直接返回消息转发到的对象(将消息发送给另一个对象去处理)
- (id)forwardingTargetForSelector:(SEL)aSelector
  • 手动生成方法签名并转发给另一个对象
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
- (void)forwardInvocation:(NSInvocation *)anInvocation;

示例

这里以一个简单的例子展示消息转发的完整个过程。定义一个Test类,类头文件声明一个名为instanceMethod的实例方法但不提供方法实现(消息转发主要就针对实例方法,类方法由于无法在运行时动态添加实现等事实并不能转发给其他类):

/* Test.h */
@interface Test : NSObject
/* 只声明一个实例方法而不在.m文件中实现 */
- (void)instanceMethod;
@end

然后在main函数中实例化Test对象并调用该实例方法,由于方法没有实现,因此在运行时一定会触发消息转发机制:

/* main.m */
#import "Test.h"
#import <objc/runtime.h>

int main(int argc, const char * argv[]) {
    Test *test = [[Test alloc] init];
    [test instanceMethod];
    return 0;
}

先进入消息转发的第一道防线,我们在Test类的.m文件中提供运行时的转发接应,实现resolveInstanceMethod方法为指定的instanceMethod消息补加对应方法的实现完成补救:

/* Test.m */
#import <objc/runtime.h>
/*
 * 被动态添加的实例方法实现
 */
void instanceMethod(id self, SEL _cmd) {
    NSLog(@"收到消息后会执行此处的函数实现...");
}

/*
 * 动态补加方法实现
 */
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(instanceMethod)) {
        class_addMethod(self, sel, (IMP)instanceMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

如果没有实现resolveInstanceMethod方法就行补救或者直接返回了NO,则进入第二道防线,这里我们要实现forwardingTargetForSelector函数返回另一个实例对象,让该对象代替原对象去处理这个消息。 假设我们让一个叫做Test2的类对象去处理这个消息,Test2类中要有同名的方法和方法的实现,这样就会执行Test2中的同名方法完成消息转发:

/* Test2.h */
@interface Test2 : NSObject
- (void)instanceMethod;
@end

/* Test2.m */
@implementation Test2
- (void)instanceMethod {
    NSLog(@"消息转发到这...");
}
@end

/* Test.m */
- (id)forwardingTargetForSelector:(SEL)aSelector {
    /* 返回转发的对象实例 */
    if (aSelector == @selector(instanceMethod)) {
        return [[Test2 alloc] init];
    }
    return nil;
}

如果没有实现上面的两个补救方法或者forwardingTargetForSelector方法直接返回了nil,则进入最后一道防线,此时我们要手动生成方法签名并实现forwardInvocation方法将消息转发给另一个对象,同第二道防线类似:

/* Test.m */
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    /* 为指定的方法手动生成签名 */
    NSString *selName = NSStringFromSelector(aSelector);
    if ([selName isEqualToString:@"instanceMethod"]) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    /* 如果另一个对象可以相应该消息,则将消息转发给他 */
    SEL sel = [anInvocation selector];
    Test2 *test2 = [[Test2 alloc] init];
    if ([test2 respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:test2];
    }
}

完整代码

main.m

#import <Foundation/Foundation.h>
#import "Test.h"

int main(int argc, const char * argv[]) {
    Test *test = [[Test alloc] init];
    [test instanceMethod];
    return 0;
}

Test

#import <Foundation/Foundation.h>

@interface Test : NSObject

- (void)instanceMethod;

@end


#import "Test.h"
#import "Test2.h"
#import <objc/runtime.h>

@implementation Test
/*
 * 被动态添加的实例方法实现
 */
void instanceMethod(id self, SEL _cmd) {
    NSLog(@"收到消息会执行此处的函数实现...");
}

/*
 * 1.第一道防线:动态补加方法实现
 */
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    // return NO;
    if (sel == @selector(instanceMethod)) {
        class_addMethod(self, sel, (IMP)instanceMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

/*
 * 2.第二道防线
 */
- (id)forwardingTargetForSelector:(SEL)aSelector {
    // return nil;
    /* 返回转发的对象实例 */
    if (aSelector == @selector(instanceMethod)) {
        return [[Test2 alloc] init];
    }
    return nil;
}

/*
 * 3.第三道防线
 */
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    /* 为指定的方法手动生成签名 */
    NSString *selName = NSStringFromSelector(aSelector);
    if ([selName isEqualToString:@"instanceMethod"]) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    return [super methodSignatureForSelector:aSelector];
}
- (void)forwardInvocation:(NSInvocation *)anInvocation {
    /* 如果另一个对象可以相应该消息,则将消息转发给他 */
    SEL sel = [anInvocation selector];
    Test2 *test2 = [[Test2 alloc] init];
    if ([test2 respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:test2];
    }
}

@end

Test2

#import <Foundation/Foundation.h>

@interface Test2 : NSObject

- (void)instanceMethod;

@end


#import "Test2.h"

@implementation Test2

- (void)instanceMethod {
    NSLog(@"消息转发到这...");
}

@end

【iOS沉思录】OC和Swift语言互调

OC和Swift之间的互调很简单,iOS8以后官方给出了这两种语言之间的桥接方案,简单说就是在Swift工程中,通过提示创建的Bridging头文件可以将OC文件和Swift文件衔接在一起,从而可以在OC文件中引用Swift类,或者在Swift文件中引用OC的类。应用较多的主要是在Swift中调用OC类,使得在最新的swift工程中仍然能够兼容使用以前的OC类库等。

这里通过一个简单的例子展示在Swift工程中OC和Swift之间的互调:

首先建立一个Swift工程,这里创建了一个Single View Application,工程名为SwiftBridge,并分别新建了一个新的名为SwiftClass的swift类文件和一个名为OCClass的OC类文件,创建第一个OC文件后会提示创建一个Bridging头文件,创建后就可以对两者进行桥接:

这里我们在ViewController.swift中调用OCClass类,同时在OCClass类中调用SwiftClass.swift。其中在swift中引用OC类时要在Bridging头文件中引入OC类的头文件(swift文件中没有头文件及其引用),而在OC中引用swift类时直接引入“SwiftBridge-swift.h”即可,不需要在Bridging头文件设置,其中“SwiftBridge”为工程名,该文件是隐藏的,对工程中所有swift类文件进行了向OC语言的翻译,从而在OC文件中可以像调用其他OC文件一样调用工程中的swift类文件。

1.在SwiftClass.swift中定义一个实例方法和一个类方法:

import UIKit

class SwiftClass: NSObject {
    func SwiftInstanceMethod() -> Void {
        print("swift instance method!");
    }
    class func SwiftClassMethod() -> Void {
        print("swift class method!");
    }
}

2.在OCClass类中调用SwiftClass:

/* OCClass.h */
#import <Foundation/Foundation.h>

@interface OCClass : NSObject
- (void)OCInstanceMethod;
+ (void)OCClassMethod;
@end

/* OCClass.m */
#import "OCClass.h"
#import "SwiftBridge-swift.h" /* 引入swift类头文件 */

@implementation OCClass
- (void)OCInstanceMethod {
    /* 调用swift实例方法 */
    SwiftClass *swiftc = [[SwiftClass alloc] init];
    [swiftc SwiftInstanceMethod];
    NSLog(@"oc instance method!");
}
+ (void)OCClassMethod {
    /* 调用swift类方法 */
    [SwiftClass SwiftClassMethod];
    NSLog(@"oc class method!");
}
@end

3.在Bridging头文件引入OC类头文件供swift调用:

/* SwiftBridge-swift.h */
#import "OCClass.h"

4.在ViewController.swift中调用OCClass类:

/* ViewController.swift */
import UIKit
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let occ = OCClass()
        occ.ocInstanceMethod()
        OCClass.ocClassMethod()
    }
}

打印结果:

swift instance method!
2017-04-12 12:40:39.657 SwiftBridge[3773:5420107] oc instance method!
swift class method!
2017-04-12 12:40:39.657 SwiftBridge[3773:5420107] oc class method!

另外类似的也可以实现swift和C语言之间的互调。

在matlab上实现遗传算法解决TSP旅行商问题

TSP问题指的是从一个节点开始遍历其他所有节点并回到初始节点,构成一个哈密顿回路,节点与节点之间距离不同,目标是找到一条回路使得总路程最短,也即就是走最短的路遍历所有节点回到起点。

遗传算法模仿达尔文进化论中优胜劣汰的思想,从随机初始总群开始,不断进化最终选出接近最优解的一代,从而求解出近似最优解

问题描述

下图矩阵展示了不同城市之间的距离,城市到自身的距离为0,现要求从Hong Kong出发,找一条最短的旅游顺序,使得游览所有城市后回到Hong Kong。 这里写图片描述

基本思路

主要是问题的编码阶段,对于TSP问题在遗传算法中编码使用整数编码,使用整数来代表每一个城市,比如这里可以依次使用1,2,3,…,13表示这13个城市,9则代表Hong Kong。

城市 编码基因
Amsterdam 1
Athens 2
Auckland 3
Bahrain 4
Bangkok 5
Colombo 6
Dubai 7
Frankflurt 8
HK 9
Jakarta 10
Kuala Lumpur 11
London 12
Manila 13

这13个数字的一个排列即是一种路径方案,但注意这条路径是一个环,收尾相接,因此起点是哪个城市是无所谓的,只要数字的相对位置确定,那13种(谁是起点)归并为同一种方案。因此所有可能的方案数为:13!/13 (全排列除以13)

示例染色体:[1 2 3 4 5 6 7 8 9 10 11 12 13],同[2 3 4 5 6 7 8 9 10 11 12 13 1]等属于同一种方案。

这种方案的路程代价为:F = 2.2+17.5+14.7+5.4+2.4+3.3+4.8+9.2+3.3+1.2+10.5+10.7+10.4 = 95.6 (thousand kilometers)

每种方案的路程代价即个体的适应度,路程越短代价越小适应度越高

函数解释

这里写图片描述

  • cost.m:用来计算一种方案的路程代价,参数为方案数列染色体和代价矩阵。算法中优选代价小的,淘汰代价大的;
  • crossover.m:染色体交叉,参数为两个父代染色体,交叉失败返回0,否则返回交叉后的两个子代染色体;
  • crosscheck.m:检查两个父代是否可交叉得到有效的子代染色体,因为交叉后如果染色体中出现重复的数字是无效的方案,等于某个城市走了两次同时有的城市没遍历到;
  • generate.m:产生一个随机的染色体,实际是将实力染色体中的基因顺序随机打乱得到;
  • mutation.m:按照一定基因突变概率使参数染色体发生基因突变得到突变后的子代染色体;
  • TSP.m:遗传算法主体,其中采用了精英选择法,将父代的优秀个体加入到下一代的竞争中。

matlab代码清单

cost.m

% 根据代价矩阵costM计算种群pop的总路程代价
function [value] = cost(pop,costM)
    % 求得种群的个体数量,从而对每个个体计算代价
    [NumP,tmp]=size(pop);
    % 循环对每个个体计算代价
    for i=1:NumP
        parent_i = pop(i,:);
        value_i = 0;
        % 累加相邻两个城市之间的距离
        for j=1:12
            value_i = value_i + costM(parent_i(j),parent_i(j+1));
        end
        % 将最后一个城市和出发城市的距离加上,组成闭合回路
        value_i = value_i + costM(parent_i(13),parent_i(1));
        % 结果组装
        value(i,1) = value_i;
    end
end

crosscheck.m

% 检查两个父代个体是否可以交叉得到有效的两个子代个体
function res = crosscheck(parent1,parent2)
    res = 0;
    % 对每个交叉点进行交叉看是否至少有一个交叉点是有效的
    for i = 1:12
        p1 = [parent1(:,1:i) parent2(:,i+1:13)];
        p2 = [parent2(:,1:i) parent1(:,i+1:13)];
        % 如果子代基因没有重复的则是有效子代
        if length(p1)==length(unique(p1)) && length(p2)==length(unique(p2))
            res = 1;
        end
    end
end

crossover.m

% 染色体交叉
function [child1, child2]=crossover(parent1, parent2)
    % 如果父代不可交叉子代置0表示无效
    child1 = 0;
    child2 = 0;

    if crosscheck(parent1,parent2) == 1
        p1 = [1 1 1 1 1 1 1 1 1 1 1 1 1];
        p2 = [2 2 2 2 2 2 2 2 2 2 2 2 2];
        % 确保子代有效
        while length(p1)>length(unique(p1)) || length(p2)>length(unique(p2))
            % 随机选择交叉点
            crossPoint = randi([1 12]);
            p1 = [parent1(:,1:crossPoint) parent2(:,crossPoint+1:13)];
            p2 = [parent2(:,1:crossPoint) parent1(:,crossPoint+1:13)];
        end
        child1 = p1;
        child2 = p2;
    end
end

generate.m

% 将基本的染色体序列随机打乱构造随机个体,可以构建num个随机个体
function pop = generate(num)
    res = [];
    % 算法思路是将tmp0中剩余的基因随机抽取依次填入tmp,当tmp0中的基因全部随机填入tmp后得到随机个体
    for i = 1:num
        tmp = [0 0 0 0 0 0 0 0 0 0 0 0 0];
        tmp0 = [1 2 3 4 5 6 7 8 9 10 11 12 13];
        for j = 1:13
            % 随机下标
            index = randi([1 14-j]);
            tmp(j) = tmp0(index);
            % 删除从tmp0染色体中被随机选中的基因
            tmp0(:,index) = [];
        end
        % 组装得到的随机个体
        res = [res;tmp];
    end
    pop = res;
end

mutation.m

% 基因突变,按照突变概率,随机交换个体中的某两个基因
function [child]=mutation(parent,probability)
    if rand() <= probability
        P = parent;
        % 随机选择两个不同的基因交换位置
        random1 = 0;
        random2 = 0;
        while random1 == random2
            random1 = randi([1 13]);
            random2 = randi([1 13]);
        end
        % 开始突变(交换位置)
        P(:,[random1,random2]) = P(:,[random2,random1]);
        child=P;
    else
        % 没有发生突变
        child = 0;
    end
end

TSP.m

% 遗传算法求解TSP旅行商问题
function tmp = TSP()
% 清空
close all;
clc;

%----------------------------data area-------------------------------------
% distance from a city to others
Amsterdam    = [0    2.2  18.1 4.8  9.2  8.4  5.2  0.4  9.3  11.3 10.2 0.4  10.4]; %1
Athens       = [2.2  0    17.5 2.8  7.9  6.6  3.3  1.8  8.5  9.8  8.7  2.4  9.6];  %2
Auckland     = [18.1 17.5 0    14.7 9.6  10.9 14.2 18.2 9.1  7.6  8.7  18.3 8.0];  %3
Bahrain      = [4.8  2.8  14.7 0    5.4  3.8  0.5  4.4  6.4  7.0  6.0  5.1  7.4];  %4
Bangkok      = [9.2  7.9  9.6  5.4  0    2.4  4.9  9.0  1.7  2.3  1.2  9.5  2.2];  %5
Colombo      = [8.4  6.6  10.9 3.8  2.4  0    3.3  8.1  4.1  3.3  2.5  8.7  4.6];  %6
Dubai        = [5.2  3.3  14.2 0.5  4.9  3.3  0    4.8  6.0  6.6  5.5  5.5  6.9];  %7
Frankflurt   = [0.4  1.8  18.2 4.4  9.0  8.1  4.8  0    9.2  11.1 10.0 0.6  10.3]; %8
HK           = [9.3  8.5  9.1  6.4  1.7  4.1  6.0  9.2  0    3.3  2.5  9.6  1.1];  %9
Jakarta      = [11.3 9.8  7.6  7.0  2.3  3.3  6.6  11.1 3.3  0    1.2  11.7 2.8];  %10
KualaLumpur  = [10.2 8.7  8.7  6.0  1.2  2.5  5.5  10.0 2.5  1.2  0    10.5 2.5];  %11
London       = [0.4  2.4  18.3 5.1  9.5  8.7  5.5  0.6  9.6  11.7 10.5 0    10.7]; %12
Manila       = [10.4 9.6  8.0  7.4  2.2  4.6  6.9  10.3 1.1  2.8  2.5  10.7 0];    %13
costM = [Amsterdam;Athens;Auckland;Bahrain;Bangkok;Colombo;Dubai;Frankflurt;HK;Jakarta;KualaLumpur;London;Manila];

%mutation probability
pmutation = 1.0;
%max generation
MaxGeneration = 200;
%poputation size
popsize = 20;
%select popsize parents from randomsize generated possible parents
randsize = 200;

%parent generations
parentpop = [];
%best parent of every generation
best_cost = [];

%-------------------generate parent generation-----------------------------
preparentpop = generate(randsize);
[A,index] = sort(cost(preparentpop,costM),1,'ascend');
%orderd preparentpop
orderedpreparentpop = preparentpop(index,:);
%selected top popsize parentpop
parentpop = orderedpreparentpop([1:popsize],:);

%---------------------main revolution loop---------------------------------
for igen = 1:MaxGeneration
    childpop = [];
    childpopsize = [0 0];
    %generate enough children
    while childpopsize(1) < popsize
        % To generate the random index for crossover and mutation 
        ind=randi(popsize,[1 2]) ;
        parent1 = parentpop(ind(1),:);
        parent2 = parentpop(ind(2),:);
        [child1,child2] = crossover(parent1,parent2);
        [child3] = mutation(parent1,pmutation);
        if child1~=0
            childpop = [childpop;child1];
        end
        if child2~=0
            childpop = [childpop;child2];
        end
        if child3~=0
            childpop = [childpop;child3];
        end
        childpopsize = size(childpop);
    end
    
    % Elite: parentpop and childpop are added together before sorting for the best popsize to continue
    allpop = [parentpop;childpop];
    [A,index] = sort(cost(allpop,costM),1,'ascend');
    orderdallpop = allpop(index,:);
    %parentpop of current generation
    parentpop = orderdallpop([1:popsize],:);
    best_cost(igen)=A(1);
end

%display
display('the best parentpop:')
parentpop
display('the lowest cost of every generation:')
best_cost'

figure,plot(1:igen,best_cost,'b') 
title('GA algorithm for TSP problem')

end

结果

进行了200代进化,大约在50代以后收敛,得到近似最优方案的最小路程代价为40.2

最优染色体为:

[2 8 12 1 9 13 3 10 11 5 6 7 4]

等效于Hong Kong为起点的:

[9 13 3 10 11 5 6 7 4 2 8 12 1]

解码得到最佳方案为:

Hong Kong - Manila - Auckland - Jakarta - Kuala Lumpur - Bangkok - Colombo - Dubai - Bahrain - Athens - Frankflurt - London - Amsterdam - Hong Kong

并不是每次运行都会得到绝对最优解,遗传算法容易早熟陷入局部最优解。

这里写图片描述

Github源码下载:https://github.com/jiangxh1992/GA4TSPProblem

Objective-C运行时特性:Method Swizzling魔法

OC运行时特性,为我们提供了一个叫做Method Swizzling的方法魔法利器,利用它我们可以更加随心所欲的在运行时期间对编译器已经的方法再次动手脚,主要包括:交换类中某两个方法的实现、重新添加或替换某个方法的具体实现。

运行时的几种特殊类型

  • Class: 类名,通过类的class类方法获得,例如:[UIViewController class];
  • SEL:选择器,也就是方法名,通过@selector(方法名:)获得,例如:@selector(buttonClicked:);
  • Method:方法,即运行时类中定义的方法,包括方法名(SEL)和方法实现(IMP)两部分,通过运行时方法class_getInstanceMethod或class_getClassMethod获得;
  • IMP:方法实现类型,指的是方法的实现部分,通过运行时方法class_getMethodImplementation或method_getImplementation获得;

替换类中某两个类方法或实例方法的实现

关键运行时函数:method_exchangeImplementations(method1, method2)

这里随便定义一个Test类,类中定义两个实例方法和类方法并在.m文件中实现,在运行时将两个实例方法的实现对调,以及将两个类方法的实现对调。注意运行时代码写在类的load方法内,该方法只会在该类第一次加载时调用一次,且写运行时代码的地方需要引入运行时头文件#import <objc/runtime.h>

Test类定义:

#import <Foundation/Foundation.h>

@interface Test : NSObject

/**
 * 定义两个公有实例方法
 */
- (void)instanceMethod1;
- (void)instanceMethod2;

/**
 * 定义两个公有类方法
 */
+ (void)classMethod1;
+ (void)classMethod2;

@end
#import "Test.h"
#import <objc/runtime.h>

@implementation Test

/**
 * runtime代码写在类第一次调加载的时候(load方法有且只有一次会被调用)
 */
+ (void)load {
    /* 1. 获取当前类名 */
    Class class = [self class];
    
    /* 2. 获取方法名(选择器) */
    SEL selInsMethod1 = @selector(instanceMethod1);
    SEL selInsMethod2 = @selector(instanceMethod2);
    
    SEL selClassMethod1 = @selector(classMethod1);
    SEL selClassMethod2 = @selector(classMethod2);
    
    /* 3. 根据方法名获取方法对象 */
    Method InsMethod1 = class_getInstanceMethod(class, selInsMethod1);
    Method InsMethod2 = class_getInstanceMethod(class, selInsMethod2);
    
    Method ClassMethod1 = class_getClassMethod(class, selClassMethod1);
    Method ClassMethod2 = class_getClassMethod(class, selClassMethod2);
    
    /* 4. 交换实例方法的实现和类方法的实现 */
    if (!InsMethod1 || !InsMethod2) {
        NSLog(@"实例方法实现运行时交换失败!");
        return;
    }
    /* 交换实例方法的实现 */
    method_exchangeImplementations(InsMethod1, InsMethod2);
    if (!ClassMethod1 || !ClassMethod2) {
        NSLog(@"类方法实现运行时交换失败!");
        return;
    }
    /* 交换类方法的实现 */
    method_exchangeImplementations(ClassMethod1, ClassMethod2);
}

/**
 * 实例方法的原实现
 */
- (void)instanceMethod1 {
    NSLog(@"instanceMethod1...");
}
- (void)instanceMethod2 {
    NSLog(@"instanceMethod2...");
}

/**
 * 类方法的原实现
 */
+ (void)classMethod1 {
    NSLog(@"classMethod1...");
}
+ (void)classMethod2 {
    NSLog(@"classMethod2...");
}

@end

测试代码:

#import "ViewController.h"
#import "Test.h"

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    /* 测试类方法调用 */
    [Test classMethod1];
    [Test classMethod2];
    
    Test *test = [[Test alloc] init];
    /* 测试实例方法调用 */
    [test instanceMethod1];
    [test instanceMethod2];
}

@end

通过下面的输出结果可知,两个实例方法和类方法的实现都被互换了:

2017-03-06 17:47:13.684 SingleView[41495:1196960] classMethod2...
2017-03-06 17:47:13.684 SingleView[41495:1196960] classMethod1...
2017-03-06 17:47:13.685 SingleView[41495:1196960] instanceMethod2...
2017-03-06 17:47:13.685 SingleView[41495:1196960] instanceMethod1...

重新设置类中某个方法的实现

关键运行时函数:method_setImplementation(method, IMP)

理解了上面的例子,我们现在略微修改其中运行时代码,通过重新设置方法的实现实现上面同样的效果

修改后的运行时代码为:

/**
 * runtime代码写在类第一次调加载的时候(load方法有且只有依次会被调用)
 */
+ (void)load {
    /* 1. 获取当前类名 */
    Class class = [self class];
    
    /* 2. 获取方法名(选择器) */
    SEL selInsMethod1 = @selector(instanceMethod1);
    SEL selInsMethod2 = @selector(instanceMethod2);
    
    SEL selClassMethod1 = @selector(classMethod1);
    SEL selClassMethod2 = @selector(classMethod2);
    
    /* 3. 根据方法名获取方法对象 */
    Method InsMethod1 = class_getInstanceMethod(class, selInsMethod1);
    Method InsMethod2 = class_getInstanceMethod(class, selInsMethod2);
    
    Method ClassMethod1 = class_getClassMethod(class, selClassMethod1);
    Method ClassMethod2 = class_getClassMethod(class, selClassMethod2);
    
    /* 下面代码为修改部分... ... */
    /* 4. 获取方法的实现 */
    IMP impInsMethod1 = method_getImplementation(InsMethod1);
    IMP impInsMethod2 = method_getImplementation(InsMethod2);
    
    IMP impClassMethod1 = method_getImplementation(ClassMethod1);
    IMP impClassMethod2 = method_getImplementation(ClassMethod2);
    
    /* 5. 重新设置方法的实现 */
    /* 重新设置instanceMethod1的实现为instanceMethod2的实现 */
    method_setImplementation(InsMethod1, impInsMethod2);
    /* 重新设置instanceMethod2的实现为instanceMethod1的实现 */
    method_setImplementation(InsMethod2, impInsMethod1);
    
    /* 重新设置classMethod1的实现为classMethod2的实现 */
    method_setImplementation(ClassMethod1, impClassMethod2);
    /* 重新设置classMethod2的实现为classMethod1的实现 */
    method_setImplementation(ClassMethod2, impClassMethod1);
}

运行后打印结果和上面方法实现交换的例子结果相同:

2017-03-06 18:27:53.032 SingleView[41879:1212691] classMethod2...
2017-03-06 18:27:53.032 SingleView[41879:1212691] classMethod1...
2017-03-06 18:27:53.033 SingleView[41879:1212691] instanceMethod2...
2017-03-06 18:27:53.033 SingleView[41879:1212691] instanceMethod1...

替换类中某个方法的实现

关键运行时函数:class_replaceMethod

这种方法只能替换实例方法的实现,而不能替换类方法的实现,修改的代码如下:

/**
 * runtime代码写在类第一次调加载的时候(load方法有且只有依次会被调用)
 */
+ (void)load {
    /* 1. 获取当前类名 */
    Class class = [self class];
    
    /* 2. 获取方法名(选择器) */
    SEL selInsMethod1 = @selector(instanceMethod1);
    SEL selInsMethod2 = @selector(instanceMethod2);
    
    SEL selClassMethod1 = @selector(classMethod1);
    SEL selClassMethod2 = @selector(classMethod2);
    
    /* 3. 根据方法名获取方法对象 */
    Method InsMethod1 = class_getInstanceMethod(class, selInsMethod1);
    Method InsMethod2 = class_getInstanceMethod(class, selInsMethod2);
    
    Method ClassMethod1 = class_getClassMethod(class, selClassMethod1);
    Method ClassMethod2 = class_getClassMethod(class, selClassMethod2);
    
    /* 4. 获取方法的实现 */
    IMP impInsMethod1 = method_getImplementation(InsMethod1);
    IMP impInsMethod2 = method_getImplementation(InsMethod2);
    
    IMP impClassMethod1 = method_getImplementation(ClassMethod1);
    IMP impClassMethod2 = method_getImplementation(ClassMethod2);
    
    /* 下面代码为修改部分... ... */
    /* 5. 获取方法编码类型 */
    const char* typeInsMethod1 = method_getTypeEncoding(InsMethod1);
    const char* typeInsMethod2 = method_getTypeEncoding(InsMethod2);
    
    const char* typeClassMethod1 = method_getTypeEncoding(ClassMethod1);
    const char* typeClassMethod2 = method_getTypeEncoding(ClassMethod2);
    
    /* 替换InsMethod1的实现为InsMethod2的实现 */
    class_replaceMethod(class, selInsMethod1, impInsMethod2, typeInsMethod2);
    /* 替换InsMethod2的实现为InsMethod1的实现 */
    class_replaceMethod(class, selInsMethod2, impInsMethod1, typeInsMethod1);
    
    class_replaceMethod(class, selClassMethod1, impClassMethod2, typeClassMethod2);
    class_replaceMethod(class, selClassMethod2, impClassMethod1, typeClassMethod1);
}

通过结果可见实例方法的实现成功被替换,而类方法的实现没有被替换:

2017-03-06 18:47:03.598 SingleView[42106:1221468] classMethod1...
2017-03-06 18:47:03.599 SingleView[42106:1221468] classMethod2...
2017-03-06 18:47:03.600 SingleView[42106:1221468] instanceMethod2...
2017-03-06 18:47:03.600 SingleView[42106:1221468] instanceMethod1...

以上介绍的是同一个类中方法实现的再改动,实际上也可以修改或交换不同类之间方法的实现。

在运行时为类补加新的方法

关键运行时函数:class_addMethod()

除了在编译期显式的定义方法,还可以在运行时补加新的实例方法,但不可以添加新的类方法,这里接上面的例子为Test在运行时添加一个新的名为newInsMethod的方法,方法的实现设置为InsMethod1的实现,修改后的运行时代码为:

/**
 * runtime代码写在类第一次调加载的时候(load方法有且只有依次会被调用)
 */
+ (void)load {
    /* 1. 获取当前类名 */
    Class class = [self class];
    
    /* 2. 获取方法名(选择器) */
    SEL selInsMethod1 = @selector(instanceMethod1);
    SEL selInsMethod2 = @selector(instanceMethod2);
    
    SEL selClassMethod1 = @selector(classMethod1);
    SEL selClassMethod2 = @selector(classMethod2);
    
    /* 3. 根据方法名获取方法对象 */
    Method InsMethod1 = class_getInstanceMethod(class, selInsMethod1);
    Method InsMethod2 = class_getInstanceMethod(class, selInsMethod2);
    
    Method ClassMethod1 = class_getClassMethod(class, selClassMethod1);
    Method ClassMethod2 = class_getClassMethod(class, selClassMethod2);
    
    /* 4. 获取方法的实现 */
    IMP impInsMethod1 = method_getImplementation(InsMethod1);
    IMP impInsMethod2 = method_getImplementation(InsMethod2);
    
    IMP impClassMethod1 = method_getImplementation(ClassMethod1);
    IMP impClassMethod2 = method_getImplementation(ClassMethod2);
    
    /* 5. 获取方法编码类型 */
    const char* typeInsMethod1 = method_getTypeEncoding(InsMethod1);
    const char* typeInsMethod2 = method_getTypeEncoding(InsMethod2);
    
    const char* typeClassMethod1 = method_getTypeEncoding(ClassMethod1);
    const char* typeClassMethod2 = method_getTypeEncoding(ClassMethod2);
    
    /* 下面代码为修改部分... ... */
    /* 6. 为类添加新的实例方法和类方法 */
    SEL selNewInsMethod = @selector(newInsMethod);
    BOOL isInsAdded = class_addMethod(class, selNewInsMethod, impInsMethod1, typeInsMethod1);
    if (!isInsAdded) {
        NSLog(@"新实例方法添加失败!");
    }
}

测试新函数代码:

/* 测试运行时新添加实例方法调用 */
    Test *test = [[Test alloc] init];
    [test newInsMethod];

运行结果打印出“instanceMethod1…”证明新实例方法动态添加成功:

2017-03-06 19:07:15.447 SingleView[42354:1230571] instanceMethod1...

除了在运行时为类添加新的方法,还可以通过其他运行时函数class_addIvar、class_addProperty、class_addProtocol等动态地为类添加新的变量、属性和协议等等。

iOS平台上的讯飞语音识别语音合成开发

官方文档:http://www.xfyun.cn/doccenter/iOS

目前开放的服务:

***

准备工作

  • 需要到讯飞官网注册一个开发账号,注册后登录并创建一个新的应用,添加需要的服务(语音听写、语音合成等等),应用创建后可以得到一个Appid,这个要在开发中初始化讯飞语音应用中用到。

  • 工程中找到Targets->Build Phases->Link Binary With Libraries,添加下面的库,注意iflyMSC.framework下载后一定要保证导入到工程目录下,而不能只是个路径引用否则会报错。另外需要在Build Settings的Build Options中关闭BitCode。

  • 在需要的地方引入讯飞语音库头文件,当然可以根据需要只引入某种服务的头文件

#import <iflyMSC/iflyMSC.h> // 引入讯飞语音库
#ifndef MSC_IFlyMSC_h
#define MSC_IFlyMSC_h

#import "IFlyAudioSession.h"
#import "IFlyContact.h"
#import "IFlyDataUploader.h"
#import "IFlyDebugLog.h"
#import "IFlyISVDelegate.h"
#import "IFlyISVRecognizer.h"
#import "IFlyRecognizerView.h"
#import "IFlyRecognizerViewDelegate.h"
#import "IFlyResourceUtil.h"
#import "IFlySetting.h"
#import "IFlySpeechConstant.h"
#import "IFlySpeechError.h"
#import "IFlySpeechEvaluator.h"
#import "IFlySpeechEvaluatorDelegate.h"
#import "IFlySpeechEvent.h"
#import "IFlySpeechRecognizer.h"
#import "IFlySpeechRecognizerDelegate.h"
#import "IFlySpeechSynthesizer.h"
#import "IFlySpeechSynthesizerDelegate.h"
#import "IFlySpeechUnderstander.h"
#import "IFlySpeechUtility.h"
#import "IFlyTextUnderstander.h"
#import "IFlyUserWords.h"
#import "IFlyPcmRecorder.h"
#import "IFlyVoiceWakeuper.h"
#import "IFlyVoiceWakeuperDelegate.h"

#endif
  • 使用Appid初始化讯飞应用,在应用启动的地方使用Appid初始化语音应用,这里放在了AppDelegate应用代理文件下:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    NSString *initString = [[NSString alloc] initWithFormat:@"appid=%@",@"587f6174"];
    [IFlySpeechUtility createUtility:initString];
    return YES;
}

语音合成和语音测评示例

这里因为要使用语音合成和测评功能所以先测试了这两个,其他的类似,可参考官方文档:

  • 应用启动后会有‘小燕’的声音读出:”Hello, this is xiaoyan!”
  • 应用启动后测评”Today is a sunny day!”的发音

先要使用Appid初始化: 这里写图片描述

//
//  ViewController.m
//  XFDemo
//
//  Created by Xinhou Jiang on 4/3/17.
//  Copyright © 2017年 Xinhou Jiang. All rights reserved.
//

#import "ViewController.h"
#import <iflyMSC/iflyMSC.h> // 引入讯飞语音库

@interface ViewController ()<IFlySpeechSynthesizerDelegate,IFlySpeechEvaluatorDelegate> { // 语音代理协议
    
}
@property (nonatomic, strong)IFlySpeechSynthesizer *iFlySpeechSynthesizer;    // 定义语音合成对象
@property (nonatomic, strong)IFlySpeechEvaluator *iFlySpeechEvaluator;        // 定义语音测评对象

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 1.开始合成说话
    //[self.iFlySpeechSynthesizer startSpeaking:@"Hello, this is xiaoyan!"];
    
    // 2.开始语音测评
    NSData *textData = [@"Today is a sunny day!" dataUsingEncoding:NSUTF8StringEncoding];
    [self.iFlySpeechEvaluator startListening:textData params:nil];
}

#pragma -mark 语音合成
/**
 * 懒加载getter方法
 */
- (IFlySpeechSynthesizer *)iFlySpeechSynthesizer {
    if(!_iFlySpeechSynthesizer) {
    // 初始化语音合成
    _iFlySpeechSynthesizer = [IFlySpeechSynthesizer sharedInstance];
    _iFlySpeechSynthesizer.delegate = self;
    // 语速【0-100】
    [_iFlySpeechSynthesizer setParameter:@"50" forKey:[IFlySpeechConstant SPEED]];
    // 音量【0-100】
    [_iFlySpeechSynthesizer setParameter:@"50" forKey:[IFlySpeechConstant VOLUME]];
    // 发音人【小燕:xiaoyan;小宇:xiaoyu;凯瑟琳:catherine;亨利:henry;玛丽:vimary;小研:vixy;小琪:vixq;小峰:vixf;小梅:vixl;小莉:vixq;小蓉(四川话):vixr;小芸:vixyun;小坤:vixk;小强:vixqa;小莹:vixying;小新:vixx;楠楠:vinn;老孙:vils】
    [_iFlySpeechSynthesizer setParameter:@"xiaoyan" forKey:[IFlySpeechConstant VOICE_NAME]];
    // 音频采样率【8000或16000】
    [_iFlySpeechSynthesizer setParameter:@"8000" forKey:[IFlySpeechConstant SAMPLE_RATE]];
    // 保存音频路径(默认在Document目录下)
    [_iFlySpeechSynthesizer setParameter:@"tts.pcm" forKey:[IFlySpeechConstant TTS_AUDIO_PATH]];
    }
    return _iFlySpeechSynthesizer;
}

/**
 * 合成结束
 */
- (void)onCompleted:(IFlySpeechError *)error {
    NSLog(@"合成结束!");
}

/**
 * 合成开始
 */
- (void)onSpeakBegin {
    NSLog(@"合成开始!");
}

/**
 * 合成缓冲进度【0-100】
 */
- (void)onBufferProgress:(int)progress message:(NSString *)msg {
    NSLog(@"合成缓冲进度:%d/100",progress);
}

/**
 * 合成播放进度【0-100】
 */
- (void)onSpeakProgress:(int)progress beginPos:(int)beginPos endPos:(int)endPos {
    NSLog(@"合成播放进度:%d/100",progress);
}

#pragma -mark 语音测评
/**
 * 懒加载getter方法
 */
- (IFlySpeechEvaluator *)iFlySpeechEvaluator {
    if (!_iFlySpeechEvaluator) {
        // 初始化语音测评
        _iFlySpeechEvaluator = [IFlySpeechEvaluator sharedInstance];
        _iFlySpeechEvaluator.delegate = self;
        // 设置测评语种【中文:zh_cn,中文台湾:zh_tw,美英:en_us】
        [_iFlySpeechEvaluator setParameter:@"en_us" forKey:[IFlySpeechConstant LANGUAGE]];
        // 设置测评题型【read_syllable(英文评测不支持):单字;read_word:词语;read_sentence:句子;read_chapter(待开放):篇章】
        [_iFlySpeechEvaluator setParameter:@"read_sentence" forKey:[IFlySpeechConstant ISE_CATEGORY]];
        // 设置试题编码类型
        [_iFlySpeechEvaluator setParameter:@"utf-8" forKey:[IFlySpeechConstant TEXT_ENCODING]];
        // 设置前、后端点超时【0-10000(单位ms)】
        [_iFlySpeechEvaluator setParameter:@"10000" forKey:[IFlySpeechConstant VAD_BOS]]; // 默认5000ms
        [_iFlySpeechEvaluator setParameter:@"1000" forKey:[IFlySpeechConstant VAD_EOS]]; // 默认1800ms
        // 设置录音超时,设置成-1则无超时限制(单位:ms,默认30000)
        [_iFlySpeechEvaluator setParameter:@"5000" forKey:[IFlySpeechConstant SPEECH_TIMEOUT]];
        // 设置结果等级,不同等级对应不同的详细程度【complete:完整 ;plain:简单】
        [_iFlySpeechEvaluator setParameter:@"" forKey:[IFlySpeechConstant ISE_RESULT_LEVEL]];
    }
    return _iFlySpeechEvaluator;
}

/**
 * 开始说话回调
 */
- (void)onBeginOfSpeech {
    NSLog(@"开始说话...");
}

/**
 * 说话结束回调
 */
- (void)onEndOfSpeech {
    NSLog(@"说话结束...");
}

/**
 * 测评结果
 */
- (void)onResults:(NSData *)results isLast:(BOOL)isLast {
    NSLog(@"测评结果:%@",results);
}

/**
 * 出错回调
 */
- (void)onError:(IFlySpeechError *)errorCode {
    NSLog(@"语音测评出错:%@",errorCode);
}

/**
 * 音量变化回调
 */
- (void)onVolumeChanged:(int)volume buffer:(NSData *)buffer {
    NSLog(@"音量变化...");
}

/**
 * 取消
 */
- (void)onCancel {
    NSLog(@"正在取消...");
}

@end

Demo 下载:https://github.com/jiangxh1992/XFSpeechDemo

【iOS沉思录】iOS添加自定义字体详解

问题:iOS中是如何使用自定义字体的?

字体是软件开发中个性化的一个重要元素,系统自带了很多丰富的字体,但有时候并不能满足个性化的需求,这时候可以向工程中添加自定义的系统字体,然后就可以像使用系统字体一样使用。字体文件最常用的为ttf等格式。

导入自定义字体过程很简单:添加资源包到工程->在info.plist文件中注册字体->在工程Bundle Resource中复制字体资源包->代码检测查询加入的字体并使用

添加资源包

addFile添加字体资源包或者直接将字体包拖到工程资源文件夹下:

info.plist文件中注册字体

在工程的info.plist属性列表中添加Fonts provided by application数组属性并在其下添加要加入的自定义字体项。注意,这里在plist文件中写的是文件的全称,包括文件后缀,文件的名字我们是可以随便改的,但建议用本来的字体族名,例如这里是:KristenITC,字体族名是不会变的,之后具体代码中使用的时候是用的字体族名而不是自定义的文件名。本来的字体族名可以右键查看字体文件的详细信息,里面的全称是本来的字体族名,而名称是自定义的。

复制资源包到Bundle Resource

检测是否成功加入字体

在具体使用之前,我们可以先通过UIFont类提供的函数打印出系统所有的字体列表,并找到我们更添加的字体看是否添加成功,还可以具体看到我们的资源包有哪些具体的字体样式,例如该字体族的斜体、粗体、粗斜体等等。打印字体族列表的代码如下:

    /**
     * 检查自定义字体族是否成功加入
     */
    // 取出系统安装了的所有字体族名
    NSArray *familyNames = [UIFont familyNames];
    NSLog(@"系统所有字体族名:%@", familyNames);
    // 打印字体族的所有子字体名(每种字体族可能对应多个子样式字体,例如每种字体族可能有粗体、斜体、粗斜体等等样式)
    for(NSString *familyName in familyNames) {
        // 字体族的所有子字体名
        NSArray *detailedNames = [UIFont fontNamesForFamilyName:familyName];
        NSLog(@"\n字体族%@的所有子字体名:%@", familyName,detailedNames);
    }

这里可以从字体组列表找到我们刚添加的字体族KristenITC:

和字体族KristenITC下的具体字体样式,这里只有一种也是默认的一种:KristenITC-Regular:

使用字体

确定字体加入系统之后就可以像自带的系统字体一样直接使用了:

// 设置label的字体和大小(这里直接使用字体族名也是可以的,有默认的子字体样式,也可以根据需求具体到自字体比如这里的:KristenITC-Regular)
    [_label setFont:[UIFont fontWithName:@"KristenITC" size:35.0]];

这里写图片描述

【iOS沉思录】iOS中的二维数组

首先我们知道OC中是没有二维数组的,二维数组是通过一位数组的嵌套实现的,但是别忘了我们有字面量,实际上可以和C/C++类似的简洁地创建和使用二维数组。这里总结了创建二维数组的两种方法以及数组的访问方式。

http://images.cnitblog.com/i/569008/201405/191628561371344.jpg

通过字面量创建和使用二维数组(推荐)

	// 1.字面量创建二维数组并访问(推荐)
    NSArray *array2d = @[
                          @[@11,@12,@13],
                          @[@21,@22,@23],
                          @[@31,@32,@33]
                          ];
    // 字面量访问方式(推荐)
    NSLog(@"array2d[2][2]:%@",array2d[2][2]);
    // 数组对象函数访问
    NSLog(@"array2d[2][2]:%@",[[array2d objectAtIndex:2] objectAtIndex:2]);

打印结果:

2017-01-05 21:59:49.694 SingleView[10483:506166] array2d[2][2]:33
2017-01-05 21:59:49.695 SingleView[10483:506166] array2d[2][2]:33

通过嵌套原本的数组对象使用二维数组

    // 2.另外一种循环嵌套穿件二维数组的方式
    NSMutableArray *mulArrayD1 = [[NSMutableArray alloc]init]; // 第一维数组
    // 添加第二维
    for(NSUInteger i = 1;i <= 3; i++) {
        NSArray *arrayD2 = @[@(i*10+1), @(i*10+2), @(i*10+3)];
        [mulArrayD1 addObject:arrayD2];
    }
    // 字面量访问方式(推荐)
    NSLog(@"array2d[2][2]:%@",mulArrayD1[2][2]);
    // 数组对象函数访问
    NSLog(@"array2d[2][2]:%@",[[mulArrayD1 objectAtIndex:2] objectAtIndex:2]);

打印结果:

2017-01-05 21:59:49.695 SingleView[10483:506166] array2d[2][2]:33
2017-01-05 21:59:49.695 SingleView[10483:506166] array2d[2][2]:33

问题: OC中是否有二维数组,如何实现?

OC中没有二维数组,但可以通过一维数组的嵌套实现。

iOS中的悬浮窗制作

Split分栏组件

【IOS沉思录】:iOS多媒体音频(上)-音频播放

问题:iOS中实现音频播放有哪些方式?

在移动应用中,我们通常将声音的播放分成两类:一种是一次性播放的简单音效(包括手机振动),音效通常很短暂,只要实现让其播放即可,播放完成即结束;另一种指的是可精确控制的音乐播放,可以控制音乐播放,暂停,继续播放,音量控制,循环播放等等。在IOS中这两种声音播放分别通过AudioToolbox.framework和AVFoundation.framework框架来实现,前者是系统音效播放,后者是AVAudioPlayer播放器对象。


使用System Sound Service播放简短音效

AudioToolbox.framework

AudioToolbox.framework是基于C语言的一个框架,播放音效其实是将短音频注册到System Sound Service,System Sound Service是一种简单、底层的声音播放服务。

System Sound Service音效播放的限制

  • 音频播放时间不能超过30s;
  • 数据必须是PCM或者IMA4格式;
  • 音频文件必须打包成“.caf .aif .wav”中的一种(实际mp3也可以);

System Sound Service播放音效的步骤

  1. 调用AudioServicesCreateSystemSoundID(CFURLRef inFileURL, SystemSoundID* outSystemSoundID)函数获得系统声音的ID;
  2. 可选择使用AudioServicesAddSystemSoundCompletion(SystemSoundID inSystemSoundID, CFRunLoopRef inRunLoop, CFStringRef inRunLoopMode, AudioServicesSystemSoundCompletionProc inCompletionRoutine, void*inClientData)方法注册回调函数监听音效播放结束事件。
  3. 调用AudioServicesPlaySystemSound(SystemSoundID inSystemSoundID)AudioServicesPlayAlertSound(SystemSoundID inSystemSoundID)方法播放音效(后者带有震动效果)。

【注】:震动效果的播放有一个专用的枚举ID变量:kSystemSoundID_Vibrate,可直接使用AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);函数播放震动效果。

示例代码和Demo

这里写图片描述

//
//  ViewController.m
//  IOSAudioEffectDemo
//
//  Created by Xinhou Jiang on 23/12/16.
//  Copyright © 2016年 Xinhou Jiang. All rights reserved.
//

#import "ViewController.h"
#import <AudioToolbox/AudioToolbox.h> // System Sound Service

// 定义sound的ID
static SystemSoundID system_sound_id_0 = 0;

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 注册声音ID
    [self registerSoundWithName:@"effect" andID:system_sound_id_0];
}

// 系统声音资源注册函数
- (void) registerSoundWithName: (NSString *)name andID:(SystemSoundID)sound_id {
    // 1.获取音频文件url
    NSString *audioFile=[[NSBundle mainBundle] pathForResource:name ofType:@"mp3"];
    NSURL *fileUrl=[NSURL fileURLWithPath:audioFile];
    // 2.将音效文件加入到系统音频服务中并返回一个长整形ID
    AudioServicesCreateSystemSoundID((__bridge CFURLRef)(fileUrl), &system_sound_id_0);
    // 3.注册一个播放完成回调函数
    AudioServicesAddSystemSoundCompletion(system_sound_id_0, NULL, NULL, soundCompleteCallback, NULL);
}

// 声音播放完成回调
void soundCompleteCallback(SystemSoundID sound_id,void *data) {
    // 声音已经播放完成...
}

// 手机震动
- (IBAction)vibrate {
    AudioServicesPlaySystemSound(kSystemSoundID_Vibrate);
}

// 播放短暂音频
- (IBAction)playShortMusic {
    AudioServicesPlaySystemSound(system_sound_id_0);  // 播放音效
    //AudioServicesPlayAlertSound(system_sound_id_0); // 播放音效并震动
}

@end

Github Demo:https://github.com/jiangxh1992/IOSAudioEffectDemo

使用AVAudioPlayer播放、控制音乐

AVAudioPlayer是一个支持多种音频格式的播放器对象,可以实现对音乐的全面控制。首先看一下AVAudioPlayer的常用属性和方法以及代理事件(只列出常用的其他的可直接到AVAudioPlayer头文件中了解):

AVAudioPlayer的属性

  • @property(readonly, getter=isPlaying) BOOL playin // 音乐是否正在播放
  • @property(readonly) NSTimeInterval duration // 整首音乐的时长
  • @property float pan // 立体声平衡,-1表示完全左声道,0表示左右声道平衡,1表示完全为右声道
  • @property float volume // 音量大小,范围为0-1.0
  • @property BOOL enableRate // 是否允许改变播放速率
  • @property float rate // 播放速率(前提enableRate要为YES),范围0.5-2.0,1.0表示正常播放
  • @property NSTimeInterval currentTime // 已经播放的时长,不包括暂停时间
  • @property(readonly) NSTimeInterval deviceCurrentTime // 设备播放音频的时间,包括暂停期间的时间
  • @property NSInteger numberOfLoops // 循环播放次数,0表示不循环,小于0表示无限循环,大于0则表示循环次数

AVAudioPlayer的实例方法

  • -(instancetype)initWithContentsOfURL:(NSURL *)url error:(NSError * *)outError // 使用文件URL初始化播放器,注意这个URL不能是HTTP URL,AVAudioPlayer不支持加载网络媒体流,只能播放本地文件
  • -(BOOL)prepareToPlay // 加载音频文件到缓冲区,这个函数如果不手动提前调用在调用play函数时也会自动调用
  • -(BOOL)play // 播放音频文件
  • -(BOOL)playAtTime:(NSTimeInterval)time // 在指定的时间开始播放音频(iOS4以后可用)
  • -(void)pause // 暂停播放
  • -(void)stop // 停止播放

AVAudioPlayer的代理事件

  • -(void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag // 音频播放完成
  • -(void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error // 音频解码发生错误

AVAudioPlayer的后台播放

上面的音乐播放只能在app内部进行,当退出应用(按下home键)时会停止播放音乐,再次进入应用时才会继续播放,因此要开启后台服务才能在应用退出后继续在后台播放音乐,一种实现方法是在AppDelegate中应用将要退出的代理事件中开启激活后台服务【但这个也只能在后台持续播放一定的时间,要想持续一直播放需要申请后台服务ID】:

- (void)applicationWillResignActive:(UIApplication *)application {
    // 开启后台处理多媒体事件
    [[UIApplication sharedApplication] beginReceivingRemoteControlEvents];
    AVAudioSession *session=[AVAudioSession sharedInstance];
    [session setActive:YES error:nil];
    // 后台播放(只能播放一定时间)
    [session setCategory:AVAudioSessionCategoryPlayback error:nil];
}

另外要在工程中开启后台模式(Targets ->Capabilities ->BackgroundModes(Audio,AirPlay,and Picture in picture) ->ON): 这里写图片描述

AVAudioPlayer使用的示例代码和Demo

这里实现一首音乐的播放,暂停,使用一个UIProcessView显示播放进度,使用一个UISlider控制音乐的音量。

//
//  ViewController.m
//  IOSMediaDemo
//
//  Created by Xinhou Jiang on 23/12/16.
//  Copyright © 2016年 Xinhou Jiang. All rights reserved.
//

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>

@interface ViewController ()<AVAudioPlayerDelegate>

@property (nonatomic, strong)AVAudioPlayer *audioPlayer;         // 定义一个播放器【一个播放器只能播放一首音乐,多首音乐需要定义一个播放器数组】
@property (nonatomic, weak)IBOutlet UIProgressView *processView; // 进度条显示音乐播放进度
@property (nonatomic, weak)IBOutlet UISlider *slider;            // 滑动条用于调节音量
@property (nonatomic, weak)NSTimer *timer;                       // 进度更新定时器

@end

@implementation ViewController

// audioPlayer懒加载getter方法
- (AVAudioPlayer *)audioPlayer {
    if (!_audioPlayer) {
        // 资源路径
        NSString *urlStr = [[NSBundle mainBundle]pathForResource:@"snow" ofType:@"wav"];
        NSURL *url = [NSURL fileURLWithPath:urlStr];
        
        // 初始化播放器,注意这里的Url参数只能为本地文件路径,不支持HTTP Url
        NSError *error = nil;
        _audioPlayer = [[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
        
        //设置播放器属性
        _audioPlayer.numberOfLoops = 0;// 不循环
        _audioPlayer.delegate = self;
        _audioPlayer.volume = 0.5; // 音量
        [_audioPlayer prepareToPlay];// 加载音频文件到缓存【这个函数在调用play函数时会自动调用】
        
        if(error){
            NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioPlayer;
}

// 视图加载
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 开启定时器
    _timer = [NSTimer scheduledTimerWithTimeInterval:0.2 target:self selector:@selector(update) userInfo:nil repeats:true];
    // 初始化进度条为0
    [self.processView setProgress:0 animated:false];
    // 初始化进度条
    [_slider setMinimumValue:0];
    [_slider setMaximumValue:1.0];
    [_slider setValue:0.5];
    [_slider setContinuous:true];
    [_slider addTarget:self action:@selector(sliderValChanged) forControlEvents:UIControlEventValueChanged];
}

// 定时更新
- (void)update {
    // 更新进度条
    if (_audioPlayer && _audioPlayer.isPlaying) {
        [_processView setProgress:(_audioPlayer.currentTime/_audioPlayer.duration) animated:true];
    }
}

// 滑动条值改变触发事件
- (void)sliderValChanged {
    // 改变音量
    if (_audioPlayer) {
        [_audioPlayer setVolume:_slider.value];
    }
}

#pragma maek -IBAction
// 播放
- (IBAction)play:(id)sender {
    [self.audioPlayer play];
}
// 暂停
- (IBAction)pause:(id)sender {
    [self.audioPlayer pause];
}

#pragma mark -播放器播放代理事件
- (void)audioPlayerDidFinishPlaying:(AVAudioPlayer *)player successfully:(BOOL)flag {
    // 播放完成...
    NSLog(@"播放完成...");
}
- (void)audioPlayerDecodeErrorDidOccur:(AVAudioPlayer *)player error:(NSError *)error {
    // 播放器解码错误...
    NSLog(@"播放器解码错误...");
}

@end

Github Demo: https://github.com/jiangxh1992/IOSAudioPlayerDemo

【IOS沉思录】:iOS多媒体音频(下)-录音及其播放

上一篇中总结了iOS中音效和音频播放的最基本使用方法,其中音频的播放控制是使用AVFoundation.framework框架中的AVAudioPlayer播放器对象来实现的,而这里音频的录制则是使用了同样框架下的一个叫AVAudioRecorder的录音机对象来实现,这两个类的用法流程非常类似,类的属性和方法也类似,例如:播放器中需要获取音频文件的url,而录音机要在沙盒中Docuemnt目录下创建一个音频文件路径url;播放器有isPlaying变量判断是否正在播放,录音机中有isRecording变量表示是否正在录制;currentTime在播放器中表示播放时间,在录音机中则表示录音时间;播放器通过prepareToPlay方法加载文件到缓冲区,录音机通过prepareToRecord创建缓冲区;播放音频有play方法,音频录制有record方法,另外都有pause暂停方法和stop停止方法等等,具体可直接打开两个类的头文件详细了解。这里实现最基本的录音流程以及录音过程的控制,并通过之前使用的AVAudioPlayer来播放录制好的音频。注意iOS录制的音频为caf格式,如果需要通用化可以通过lame等插件将caf格式音频转成mp3格式。


##录音

这里实现开始录音,暂停,继续以及停止录音。

创建文件目录

iOS沙盒内胡要有三个目录:Documents目录,tmp目录以及Library目录,其中Documents目录用来存放用户的应用程序数据,需要定期备份的数据要放在这里,和plist文件存储一样,我们要找到存放文件的路径,然后在该路径下放一个我们的文件,因此要自定义一个带后缀的文件名,将获得的路径和文件名拼在一起记得到我们的文件的绝对路径:

// 文件名
#define fileName_caf @"demoRecord.caf"
// 录音文件绝对路径
@property (nonatomic, copy) NSString *filepathCaf;

// 获取沙盒Document文件路径
NSString *sandBoxPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
// 拼接录音文件绝对路径
_filepathCaf = [sandBoxPath stringByAppendingPathComponent:fileName_caf];

创建音频会话

录音前要创建一个音频会话,同时要设置录音类型,提供的类型有以下几种:

  • AVF_EXPORT NSString *const AVAudioSessionCategoryAmbient; // 用于录制背景声音,像雨声、汽车引擎发动噪音等,可和其他音乐混合
  • AVF_EXPORT NSString *const AVAudioSessionCategorySoloAmbient; // 也是背景声音,但其他音乐会被强制停止
  • AVF_EXPORT NSString *const AVAudioSessionCategoryPlayback; // 音轨
  • AVF_EXPORT NSString *const AVAudioSessionCategoryRecord; // 录音
  • AVF_EXPORT NSString *const AVAudioSessionCategoryPlayAndRecord; // 录音和回放
  • AVF_EXPORT NSString *const AVAudioSessionCategoryAudioProcessing; // 用于底层硬件编码信号处理等
  • AVF_EXPORT NSString *const AVAudioSessionCategoryMultiRoute; // 内置硬件相关,iOS 6.0以上可用

常用的是AVAudioSessionCategoryPlayAndRecord类型,便于录音后播放。

// 创建音频会话
AVAudioSession *audioSession=[AVAudioSession sharedInstance];
// 设置录音类别(这里选用录音后可回放录音类型)
[audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:nil];
[audioSession setActive:YES error:nil];

录音设置

录音前要根据需要对录音进行一些相应的基本设置,例如录音格式(LinearPCM)、采样率、通道数等等,设置保存在一个字典内并作为初始化录音机的一个参数。

// 录音设置
-(NSDictionary *)getAudioSetting{
    // LinearPCM 是iOS的一种无损编码格式,但是体积较为庞大
    // 录音设置信息字典
    NSMutableDictionary *recordSettings = [[NSMutableDictionary alloc] init];
    // 录音格式
    [recordSettings setValue :@(kAudioFormatLinearPCM) forKey: AVFormatIDKey];
    // 采样率
    [recordSettings setValue :@11025.0 forKey: AVSampleRateKey];
    // 通道数(双通道)
    [recordSettings setValue :@2 forKey: AVNumberOfChannelsKey];
    // 每个采样点位数(有8、16、24、32)
    [recordSettings setValue :@16 forKey: AVLinearPCMBitDepthKey];
    // 采用浮点采样
    [recordSettings setValue:@YES forKey:AVLinearPCMIsFloatKey];
    // 音频质量
    [recordSettings setValue:@(AVAudioQualityMedium) forKey:AVEncoderAudioQualityKey];
    // 其他可选的设置
    // ... ...
    
    return recordSettings;
}

创建录音机对象

录音机对象的创建主要是利用上面的保存路径和录音设置进行初始化得到:

// 懒加载录音机对象get方法
- (AVAudioRecorder *)audioRecorder {
    if (!_audioRecorder) {
        // 保存录音文件的路径url
        NSURL *url = [NSURL URLWithString:_filepathCaf];
        // 创建录音格式设置setting
        NSDictionary *setting = [self getAudioSetting];
        // error
        NSError *error=nil;
        
        _audioRecorder = [[AVAudioRecorder alloc]initWithURL:url settings:setting error:&error];
        _audioRecorder.delegate = self;
        _audioRecorder.meteringEnabled = YES;// 监控声波
        if (error) {
            NSLog(@"创建录音机对象时发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioRecorder;
}

录音控制方法

录音过程控制主要是开始录音、暂停、继续和停止录音,其中开始录音和继续录音都是record方法。

// 开始录音或者继续录音
- (IBAction)startOrResumeRecord {
    // 注意调用audiorecorder的get方法
    if (![self.audioRecorder isRecording]) {
        // 如果该路径下的音频文件录制过则删除
        [self deleteRecord];
        // 开始录音,会取得用户使用麦克风的同意
        [_audioRecorder record];
    }
}

// 录音暂停
- (IBAction)pauseRecord {
    if (_audioRecorder) {
        [_audioRecorder pause];
    }
}

// 结束录音
- (IBAction)stopRecord {
    [_audioRecorder stop];
}

录音播放

录音的播放很简单,就是之前AVAudioPlayer音频播放的简单应用,播放的路径即我们录音时创建好的音频路径。但这里注意为了保证每次都播放最新录制的音频,播放器的get方法要每次重新创建初始化。

// audioPlayer懒加载getter方法
- (AVAudioPlayer *)audioPlayer {
    _audioRecorder = NULL; // 每次都创建新的播放器,删除旧的
    
    // 资源路径
    NSURL *url = [NSURL fileURLWithPath:_filepathCaf];
    
    // 初始化播放器,注意这里的Url参数只能为本地文件路径,不支持HTTP Url
    NSError *error = nil;
    _audioPlayer = [[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
    
    //设置播放器属性
    _audioPlayer.numberOfLoops = 0;// 不循环
    _audioPlayer.delegate = self;
    _audioPlayer.volume = 0.5; // 音量
    [_audioPlayer prepareToPlay];// 加载音频文件到缓存【这个函数在调用play函数时会自动调用】
    
    if(error){
        NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
        return nil;
    }
    
    return _audioPlayer;
}

// 播放录制好的音频
- (IBAction)playRecordedAudio {
    // 没有文件不播放
    if (![[NSFileManager defaultManager] fileExistsAtPath:self.filepathCaf]) return;
    // 播放最新的录音
    [self.audioPlayer play];
}

完整源码和Demo下载

//
//  ViewController.m
//  IOSRecorderDemo
//
//  Created by Xinhou Jiang on 29/12/16.
//  Copyright © 2016年 Xinhou Jiang. All rights reserved.
//

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>

// 文件名
#define fileName_caf @"demoRecord.caf"

@interface ViewController ()

// 录音文件绝对路径
@property (nonatomic, copy) NSString *filepathCaf;
// 录音机对象
@property (nonatomic, strong) AVAudioRecorder *audioRecorder;
// 播放器对象,和上一章音频播放的方法相同,只不过这里简单播放即可
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
// 用一个processview显示声波波动情况
@property (nonatomic, weak) IBOutlet UIProgressView *processView;
// 用一个label显示录制时间
@property (nonatomic, weak) IBOutlet UILabel *recordTime;
// UI刷新监听器
@property (nonatomic, strong) NSTimer *timer;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 初始化工作
    [self initData];
}

// 初始化
- (void)initData {
    // 获取沙盒Document文件路径
    NSString *sandBoxPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    // 拼接录音文件绝对路径
    _filepathCaf = [sandBoxPath stringByAppendingPathComponent:fileName_caf];
    
    // 1.创建音频会话
    AVAudioSession *audioSession=[AVAudioSession sharedInstance];
    // 设置录音类别(这里选用录音后可回放录音类型)
    [audioSession setCategory:AVAudioSessionCategoryPlayAndRecord error:nil];
    [audioSession setActive:YES error:nil];
    
    // 2.开启定时器
    [self timer];
}

#pragma mark -录音设置工具函数
// 懒加载录音机对象get方法
- (AVAudioRecorder *)audioRecorder {
    if (!_audioRecorder) {
        // 保存录音文件的路径url
        NSURL *url = [NSURL URLWithString:_filepathCaf];
        // 创建录音格式设置setting
        NSDictionary *setting = [self getAudioSetting];
        // error
        NSError *error=nil;
        
        _audioRecorder = [[AVAudioRecorder alloc]initWithURL:url settings:setting error:&error];
        _audioRecorder.delegate = self;
        _audioRecorder.meteringEnabled = YES;// 监控声波
        if (error) {
            NSLog(@"创建录音机对象时发生错误,错误信息:%@",error.localizedDescription);
            return nil;
        }
    }
    return _audioRecorder;
}

// audioPlayer懒加载getter方法
- (AVAudioPlayer *)audioPlayer {
    _audioRecorder = NULL; // 每次都创建新的播放器,删除旧的
    
    // 资源路径
    NSURL *url = [NSURL fileURLWithPath:_filepathCaf];
    
    // 初始化播放器,注意这里的Url参数只能为本地文件路径,不支持HTTP Url
    NSError *error = nil;
    _audioPlayer = [[AVAudioPlayer alloc]initWithContentsOfURL:url error:&error];
    
    //设置播放器属性
    _audioPlayer.numberOfLoops = 0;// 不循环
    _audioPlayer.delegate = self;
    _audioPlayer.volume = 0.5; // 音量
    [_audioPlayer prepareToPlay];// 加载音频文件到缓存【这个函数在调用play函数时会自动调用】
    
    if(error){
        NSLog(@"初始化播放器过程发生错误,错误信息:%@",error.localizedDescription);
        return nil;
    }
    
    return _audioPlayer;
}

// 计时器get方法
- (NSTimer *)timer {
    if (!_timer) {
        _timer = [NSTimer scheduledTimerWithTimeInterval:0.1f repeats:YES block:^(NSTimer * _Nonnull timer) {
            if(_audioRecorder) {
                // 1.更新录音时间,单位秒
                int curInterval = [_audioRecorder currentTime];
                _recordTime.text = [NSString stringWithFormat:@"%02d:%02d",curInterval/60,curInterval%60];
                // 2.声波显示
                //更新声波值
                [self.audioRecorder updateMeters];
                //第一个通道的音频,音频强度范围:[-160~0],这里调整到0~160
                float power = [self.audioRecorder averagePowerForChannel:0] + 160;
                [_processView setProgress:power/160.0];
            }
        }];
    }
    return _timer;
}

// 录音设置
-(NSDictionary *)getAudioSetting{
    // LinearPCM 是iOS的一种无损编码格式,但是体积较为庞大
    // 录音设置信息字典
    NSMutableDictionary *recordSettings = [[NSMutableDictionary alloc] init];
    // 录音格式
    [recordSettings setValue :@(kAudioFormatLinearPCM) forKey: AVFormatIDKey];
    // 采样率
    [recordSettings setValue :@11025.0 forKey: AVSampleRateKey];
    // 通道数(双通道)
    [recordSettings setValue :@2 forKey: AVNumberOfChannelsKey];
    // 每个采样点位数(有8、16、24、32)
    [recordSettings setValue :@16 forKey: AVLinearPCMBitDepthKey];
    // 采用浮点采样
    [recordSettings setValue:@YES forKey:AVLinearPCMIsFloatKey];
    // 音频质量
    [recordSettings setValue:@(AVAudioQualityMedium) forKey:AVEncoderAudioQualityKey];
    // 其他可选的设置
    // ... ...
    
    return recordSettings;
}

// 删除filepathCaf路径下的音频文件
-(void)deleteRecord{
    NSFileManager* fileManager=[NSFileManager defaultManager];
    if ([[NSFileManager defaultManager] fileExistsAtPath:self.filepathCaf]) {
        // 文件已经存在
        if ([fileManager removeItemAtPath:self.filepathCaf error:nil]) {
            NSLog(@"删除成功");
        }else {
            NSLog(@"删除失败");
        }
    }else {
        return; // 文件不存在无需删除
    }
}

#pragma mark -录音流程控制函数
// 开始录音或者继续录音
- (IBAction)startOrResumeRecord {
    // 注意调用audiorecorder的get方法
    if (![self.audioRecorder isRecording]) {
        // 如果该路径下的音频文件录制过则删除
        [self deleteRecord];
        // 开始录音,会取得用户使用麦克风的同意
        [_audioRecorder record];
    }
}

// 录音暂停
- (IBAction)pauseRecord {
    if (_audioRecorder) {
        [_audioRecorder pause];
    }
}

// 结束录音
- (IBAction)stopRecord {
    [_audioRecorder stop];
}

#pragma mark -录音播放
// 播放录制好的音频
- (IBAction)playRecordedAudio {
    // 没有文件不播放
    if (![[NSFileManager defaultManager] fileExistsAtPath:self.filepathCaf]) return;
    // 播放最新的录音
    [self.audioPlayer play];
}

@end

Demo下载:https://github.com/jiangxh1992/iOSAudioRecorderDemo

一步步学OpenGL(50) -《Vulkan火山引擎介绍》

教程 50

Vulkan火山引擎介绍

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial50/tutorial50.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

【提前将作者的最新这一篇翻译过来】

You’ve probably heard by now quite a bit about Vulkan, the new Graphics API from Khronos (the non profit organization responsible for the development of OpenGL). Vulkan was announced in Feb-2016 and after 24 years with OpenGL it is a completely new standard and a departure from the current model. I won’t go into many details about the various features of Vulkan only to say that in comparison to OpenGL it is much more low level and provides a lot of power and performance opportunities for the developer. But with great power comes great responsibility. The developer has to take charge of various aspects such as command buffer, synchronization and memory management that were previously the sole responsibility of the driver. Through the unique knowledge that the developer has about the way the application is structured, the usage of the Vulkan API can be tailored in a way to increase the overall performance of the system. The thing that surprises people the most, IMHO, about Vulkan is the amount of code that must be written only to get the first triangle on the screen. Comparing this to the few lines we had to write in OpenGL in the first few tutorials this is a major change and becomes a challenge when one tries to write a tutorial about it. Therefore, as always with OGLDEV, I’ll try to present the material step by step. We will develop our first triangle demo in a few tutorials, making additional progress in each one. In addition, instead of laying out the dozens of APIs in one long piece of code I’ll present a simple software design that I hope will make it simpler for you to understand without imposing too much restrictions on your future apps. Consider this an educational design which you are free to throw away later. We will study the core components of Vulkan one by one as we make progress throught the code so at this point I just want to present a diagram of the general picture: This diagram is by all means not a complete representation. It includes only the major components that will probably be present in most applications. The connectors between the objects represent the dependencies between them at creation or enumeration time. For example, in order to create a surface you need an instance object and when you enumerate the physical devices on your system you also need an instance. The two colors roughly describe the software design that we will use. The dark red objects will go into something I call the “core” and the light green objects will go into the “app”. We will later see why this makes sense. The application code that you will write will actually inherit from “app” and all of its members will be available for you for further use. I hope this design will provide a solid base to develop future Vulkan tutorials.

系统配置

The first thing we need to do is to make sure your system supports Vulkan and get everything ready for development. You need to verify that your graphics card supports Vulkan and install the latest drivers for it. Since Vulkan is still new it’s best to check for drivers updates often because hardware vendors will probably fix a lot of bugs before everything stabilizes. Since there are many GPUs available I can’t provide much help here. Updating/installing the driver on Windows should be fairly simple. On Linux the process may be a bit more involved. My main development system is Linux Fedora and I have a GT710 card by NVIDIA. NVIDIA provide a binary run file which can only be installed from the command line. Other vendors have their own processes. On Linux you can use the ‘lspci’ to scan your system for devices and see what GPU you have. You can use the ‘-v’, ‘-vv’ and ‘-vvv’ options to get increasingly more info on your devices. The second thing we need is the Vulkan SDK by Khronos, available here. The SDK includes the headers and libraries we need as well as many samples that you can use to get more info beyond what this tutorial provides. At the time of writing this the latest version is 1.0.30.0 and I urge you to update often because the SDK is in active development. That version number will be used throughout the next few sections so make sure you change it according to the version you have.

Linux

Khronos provides a package only for Ubuntu in the form of an executable run file. Executing this file should install everything for you but on Fedora I encoutered some difficulties so I used the following procedure (which is also forward looking in terms of writing the code later):

bash$ chmod +x vulkansdk-linux-x86_64-1.0.30.0.run

base$ ./vulkansdk-linux-x86_64-1.0.30.0.run --target VulkanSDK-1.0.30.0 --noexec

base$ ln -s ~/VulkanSDK-1.0.30/1.0.30.0 ~/VulkanSDK

The above commands extract the contents of the package without running its internal scripts. After extraction the directory VulkanSDK-1.0.30.0 will contain a directory called 1.0.30.0 where the actual content of the package will be located. Let’s assume I ran the above commands in my home directory (a.k.a in bash as ‘~’) so we should end up with a ‘~/VulkanSDK’ symbolic link to the directory with the actual content (directories such as ‘source’, ‘samples’, etc). This link makes it easier to switch your development environment to newer versions of the SDK. It points to the location of the headers and libraries that we need. We will see later how to connect them to the rest of the system. Now do the following:

bash$ cd VulkanSDK/1.0.30.0

bash$ ./build_examples.sh

If everything went well the examples were built into ‘examples/build’. To run the examples you must first cd into that directory. You can now run ‘./cube’ and ‘./vulkaninfo’ to make sure Vulkan runs on your system and get some useful information on the driver. Hopefully everything is OK so far so we want to create some symbolic links that will make the files we need for development easily accessible from our working environment. Change to the root user (by executing ‘su’ and entering the root password) and execute the following:

bash# ln -s /home/<your username>/VulkanSDK/x86_x64/include/vulkan /usr/include

base# ln -s /home/<your username>/VulkanSDK/x86_x64/lib/libvulkan.so.1 /usr/lib64

base# ln -s /usr/lib64/libvulkan.so.1 /usr/lib64/libvulkan.so

What we did in the above three commands is to create a symbolic link from /usr/include to the vulkan header directory. We also created a couple of symbolic links to the shared object files against which we are going to link our executables. From now one whenever we download a new version of the SDK we just need to change the symbolic link ‘~/VulkanSDK’ to the new location in order to keep the entire system up to date. To emphasis: the procedure as the root user must only be executed once. When you get a newer version of the SDK you will only need to extract it and update the symbolic link from your home directory. You are free to place that link anywhere you want but the code I provide will assume it is in the home directory so you will need to fix that.

Windows

Installation on Windows is simpler than on Linux. You just need to get the latest version from here, double click the executable installer and after agreeing to the license agreement and selecting the target directory you are done. I suggest you install the SDK under c:\VulkanSDK to make it compatible with the Visual Studio solution that I provide, but it is not a must. If you install it somewhere else make sure you update the include and link directories in the project files. See details in the next section.

创建运行

Linux

My main development environment on Linux is Netbeans. The source code that accompanies all my tutorials contains project files which can be used with the C/C++ Netbeans download bundle. If you followed the above system setup procedure then these projects should work out of the box for you (and please let me know if there are any problems). If you are using a different build system you need to make sure to add the following:

  • To the compile command: -I<path to VulkanSDK/1.0.30.0/x86_64/include>
  • To the link command: -L<path to VulkanSDK/1.0.30.0/x86_64/lib> -lxcb -lvulkan'

Even if you don’t use Netbeans I suggest you go into ‘ogldev/tutorial50’ after you unzip the tutorial source package and run ‘make’. I provide the makefiles that Netbeans generates so you can check whether your system is able to build them or something is missing. If everything was ok you can now run 'dist/Debug/GNU-Linux-x86/tutorial50'from within ‘ogldev/tutorial50’.

Windows

If you installed the SDK under ‘c:\VulkanSDK’ then the Visual Studio project files I supply should work out of the box. If you haven’t or you want to setup a Visual Studio project from scratch then follow the steps below. To update the include directory right click on the project in the solution explorer, go to ‘Properties’ and then to ‘Configuration Properties -> C/C++ -> General’. Now you must add ‘c:\VulkanSDK<version>\Include’ to ‘Additional Include Directories’. See example below: To update the link directory right click on the project in the solution explorer, go to ‘Properties’ and then to ‘Configuration Properties -> Link -> General’. Now you must add ‘c:\VulkanSDK<version>\Bin32’ to ‘Additional Library Directories’. See example below: While you are still in the linker settings go to ‘Input’ (just one below ‘General’) and add ‘vulkan-1.lib’ to ‘Additional Dependencies”.

个人评价观点

Before we get going I have a few comments about some of my design choices with regard to Vulkan:

  • Many Vulkan functions (particularly the ones used to create objects) take a structure as one of the parameters. This structure usually serve as a wrapper for most of the parameters the function needs and it helps in keeping the number of parameters to the function low. The Vulkan architects decided to place a member called sType as the first member in all these structures. This member is of an enum type and every structure has its own code. This allows the driver to identify the type of the structure using only its address. All of these enum code have a VK_STRUCTURE_TYPE_ prefix. For example, the code for the structure used in the creation of the instance is called VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO. Whenever I declare a variable of one of these structure types the first member I update will be sType. To save time I won’t comment about it later in the source walkthrough.

  • Another comment on these Vulkan structures - they contain quite a lot of stuff which we don’t need in our first few steps. To keep the code as short as possible (as well as the text here…) I always initialize the memory of all structures to zero (using the struct = {} notation) and I will only set and describe the structure members that cannot be zero. I will discuss the stuff that I skipped in future tutorials as they become relevant.

  • Vulkan functions are either void or they return a VkResult which is the error code. The error code is an enum where VK_SUCCESS is zero and everything else is greater than zero. When it is possible I check the return value for errors. If an error occured I print a message to the console (on Windows there should be a message box) and exit. Error handling in real world applications tend to make the code more complex and I want to keep it as simple as possible.

  • Many Vulkan functions (particularly of creation type) can take a pointer to an allocator function. These allocators allow you to control the process of allocating memory that the Vulkan functions need. I consider this as an advanced topic and will not discuss it. We will pass NULL as the allocators so the driver will use its default.

  • Vulkan does not guarantee that its functions will be automatically exposed by the implementing library. This means that on some platforms you might get a segmentation fault when you call a Vulkan function because it turns out to be a NULL. In these cases you have to use vkGetInstanceProcAddr() to get the function address before it is used (remember that with OpenGL we had GLEW to save us from all this hassle). My personal experience with my driver was that only vkCreateDebugReportCallbackEXT() was not available. This function is only required for the optional validation layer. Therefore, I decided to take a risk and release the tutorial without fetching the addresses for all the functions that I used. If readers will report problems on their platforms I will update the code.

  • Every serious software has to deal with object deallocation or it will eventually run out of memory. In this tutorial I’m keeping things simple and not destroying any of the objects that I allocate. They are destroyed anyway when the program shuts down. I will probably revisit this topic in the future but for now just remember that almost every <vkCreate() function has a corresponding vkDestroy() and you need to be careful if you are destroying stuff while the program is running. You can find more information about it here.

代码结构

Here’s a short summary of the files that contain the code that we are going to review. The path relates to the root of the ogldev software package:

  • tutorial50/tutorial50.cpp - location of the main() function.

  • include/ogldev_vulkan.h - primary header for all of our Vulkan code. This is the only place where the Vulkan headers by Khronos are included. You can enable the validation layer here by uncommenting ENABLE_DEBUG_LAYERS. This file contains a few Vulkan helper functions and macros as well as the definition of the VulkanWindowControl class.

  • Common/ogldev_vulkan.cpp - implementation of the functions defined in ogldev_vulkan.h

  • include/ogldev_vulkan_core.h - declaration of the OgldevVulkanCore which is the primary class that we will develop.

  • Common/ogldev_vulkan_core.cpp - implementation of the OgldevVulkanCore class.

  • include/ogldev_xcb_control.h - declaration of the XCBControl class that creates a window surface on Linux.

  • Common/ogldev_xcb_control.cpp - implementation of XCBControl.

  • include/ogldev_win32_control.h - declaration of the Win32Control class that creates a window surface on Windows.

  • Common/ogldev_win32_control.cpp - implementation of Win32Control.

Note that on both Netbeans and Visual Studio the files are divided between the ‘tutorial50’ and ‘Common’ projects.

源代码详解

… …

一步步学OpenGL(33) -《实例渲染》

教程 33

实例渲染

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial33/tutorial33.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

源代码详解

一步步学OpenGL(32) -《顶点数组对象VOA》

教程 32

顶点数组对象VOA

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial32/tutorial32.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

源代码详解

一步步学OpenGL(31) -《PN三角曲面细分》

教程 31

PN三角曲面细分

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial31/tutorial31.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

源代码详解

一步步学OpenGL(24) -《曲面细分基础》

教程 30

曲面细分基础

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial30/tutorial30.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

源代码详解

一步步学OpenGL(29) -《三维拾取》

教程 29

三维拾取

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial29/tutorial29.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

源代码详解

一步步学OpenGL(28) -《Transform Feedback粒子系统》

教程 28

Transform Feedback粒子系统

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial28/tutorial28.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

粒子系统是一种模拟像烟雾、灰尘、火焰、雨等自然现象的技术统称。这些自然现象的共性是它们都是由大量的小粒子组成的,并按照不同现象的特性进行特定形态的整体运动。

为了模拟由大量粒子组成的自然现象,我们通常要维护每个粒子的位置信息和其他一些属性(例如速度、颜色等等),并且在每一帧要进行下面的一些操作:

  1. 更新每一个粒子的属性。这一步通常包含一些数学计算(从简单计算到复杂计算,复杂性取决于要模拟的自然现象的复杂度)。

  2. 渲染粒子(将每个点渲染成简单的颜色或者利用billboard公告板技术实现)

在过去,步骤一通常发生在CPU上,应用将会访问顶点缓冲器,扫描里面的内容并且更新每个粒子的属性。步骤二更直接,和其他类型的渲染一样发生在GPU上。但这种方法存在两个问题:

  • 在CPU上更新粒子需要OpenGL驱动从GPU内存中复制顶点缓冲器中的内容到CPU中(在独立显卡中这意味着要通过PCI总线传输数据)。通常我们需要模拟的现象是需要大量的粒子的,数以万计的粒子需求情况也不是少数。如果每个粒子都要占用64 bytes且运行时每秒60帧(很好的帧率了),意味着每秒钟要在GPU和CPU之间来回拷贝640K的内容60次,这会大大影响应用的性能,且随着粒子数量的增加性能也会越差。

  • 更新粒子属性意味着在不同的数据项中运行相同的数学运算,这是一个GPU最擅长的典型的分布式计算的例子,而如果运行在CPU上则意味着要串行运行整个过程。如果CPU是多核的,那么我们可以利用它降低整体的计算时间,但这会需要在应用中做更多额外的工作。而将更新过程运行在GPU上就相当于可以直接获得并行执行的效果。

DirectX10引入了一种叫做Stream Output的新特性,对于实现粒子系统很有用。OpenGL3.0中最后也加入了该新特性并称之为Transform Feedback。该特性背后的思想是,我们可以绑定一个特殊类型的缓冲器(称其为Transform Feedback Buffer,就在几何着色器之后,当然如果几何着色器被忽略的话就是在顶点着色器之后了),并将变换之后的图元传递给该缓冲器。另外,我们可以决定是否让图元继续进行后面常规的光栅化流程。相同的缓冲器下,上一帧输出的顶点信息可以作为缓存用于下一帧的输入。在这个循环中,上面的两步可以完全发生在GPU上而不需要我们应用的参与(并不是为每一帧都绑定相应的缓冲器并设置一些状态)。下面的示意图展示了管线的新的结构:

那么在transform feedback buffer中最后到底有多少图元呢?如果没有用到几何着色器答案就简单了,只要根据当前帧的顶点数即可直接计算。但是如果用到了几何着色器,那么图元的数量就是未知的了,因为在几何着色器过程中是可以添加或者删除图元的(甚至可以包含循环和分支),我们无法总能在缓冲器中计算最终的图元数量。因此,不知道确切的顶点数量之后我们怎么绘制呢?为了解决这个困难,transform feedback引入了一种新的绘制函数,这个函数回调不需要使用顶点数量作为参数。系统会自动为每一个buffer计算顶点数量,之后当buffer缓存作为输入时可在内部使用那个计算好的顶点数量。如果多次将数据输入到transform feedback buffer中(缓冲到里面但是不作为之后的输入)相应的顶点数量也会随之更新增加。我们可以选择随时在buffer内部更新缓存偏移值,同时系统也会根据我们设置的偏移值更新顶点数量值。

在这个教程中,我们将使用transform feedback来模拟火焰效果。火焰模拟中的数学计算相对来说比较容易,因此这里我们重点介绍transform feedback的使用和实现。该框架之后也可以应用到其他类型的粒子系统。

OpenGL有一个强制限制,就是在同一个绘制回调中相同的资源不可以同时绑定作为输入和输出。这意味着如果我们想在顶点缓冲器中更新粒子,我们实际需要两个transform feedback buffer并交替使用它们。在第0帧,我们将在buffer A中更新粒子,并在buffer B中渲染粒子。然后在第1帧中我们将在buffer B中更新粒子,在buffer A中渲染粒子。当然所有这些使用者是无需关心的。

此外,我们也有两个着色器:一个负责更新粒子,另一个负责渲染粒子。我们也会使用前面教程中介绍的公告板技术来渲染。

源代码详解

(particle_system.h:29)

class ParticleSystem
{
public:
    ParticleSystem();

    ~ParticleSystem();

    bool InitParticleSystem(const Vector3f& Pos);

    void Render(int DeltaTimeMillis, const Matrix4f& VP, const Vector3f& CameraPos);

private:

    bool m_isFirst;
    unsigned int m_currVB;
    unsigned int m_currTFB;
    GLuint m_particleBuffer[2];
    GLuint m_transformFeedback[2];
    PSUpdateTechnique m_updateTechnique;
    BillboardTechnique m_billboardTechnique;
    RandomTexture m_randomTexture;
    Texture* m_pTexture;
    int m_time;
};

ParticleSystem类封装了所有管理transform feedback buffer的机制,应用可以实例化ParticleSystem类并使用火焰发射器的世界坐标位置初始化它。在主渲染循环中,ParticleSystem::Render()函数会被调用,并接收三个参数:从上一个回调的毫秒时间差,视图窗口矩阵和投影矩阵的乘积,和相机的世界空间位置。

这个类还有几个属性:一个是Render()函数第一次被调用的标记变量;两个索引,一个指明当前的定点缓冲器(作为输入),另一个指定transform feedback buffer(作为输出);还有两个顶点缓冲器句柄和两个transform feedback对象句柄;更新和渲染着色器;一个包含随机数的纹理,这个纹理将会贴到离子上;最后还有当前的全局时间变量。

(particle_system.cpp:31)

struct Particle
{
    float Type; 
    Vector3f Pos;
    Vector3f Vel; 
    float LifetimeMillis; 
};

每一个粒子都可用上面的结构体来表示。一个例子可以是一个发射器,可以是发射器分裂产生的shell或者secondary shell。发射器是静态的,负责产生其他粒子,且在系统中是独一无二的。发射器周期性的创建shell粒子,并往上发射出去,几秒后shells爆炸分裂出secondary shells并飞向随机方向。除了发射器,所有的粒子都有它们的生命周期,系统以毫秒为时间单位计算它们。当粒子的生命周期达到一定时间后就会被移除。另外每个粒子都有它们的位置和速度。当一个粒子被创建后会被给予一个速度向量,这个速度会受到重力的影响使其下落。在每一帧,我们使用速度向量来更新粒子的世界坐标,然后使用更新的坐标来渲染粒子。

(particle_system.cpp:67)

bool ParticleSystem::InitParticleSystem(const Vector3f& Pos)
{ 
    Particle Particles[MAX_PARTICLES];
    ZERO_MEM(Particles);

    Particles[0].Type = PARTICLE_TYPE_LAUNCHER;
    Particles[0].Pos = Pos;
    Particles[0].Vel = Vector3f(0.0f, 0.0001f, 0.0f);
    Particles[0].LifetimeMillis = 0.0f;

    glGenTransformFeedbacks(2, m_transformFeedback); 
    glGenBuffers(2, m_particleBuffer);

    for (unsigned int i = 0; i < 2 ; i++) {
        glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, m_transformFeedback[i]);
        glBindBuffer(GL_ARRAY_BUFFER, m_particleBuffer[i]);
        glBufferData(GL_ARRAY_BUFFER, sizeof(Particles), Particles, GL_DYNAMIC_DRAW);
        glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_particleBuffer[i]);
    }

这是粒子系统初始化的第一部分。我们在栈上为所有的粒子开辟空间,并只初始化第一个粒子作为发射器(其他的粒子在渲染时再创建)。发射器的位置也是所有要创建的粒子的起点,发射器的速度也是所有新创建粒子的初始速度(发射器自己是静止的)。我们将要使用两个transform feedback缓冲器并在他们之间切换(使用其中一个绘制输出的同时,使用另一个作为输入,反之亦然)。我们可以使用glGenTransformFeedbacks函数创建两个transform feedback对象,它们封装了所有绑定到它们上面的状态。另外创建两个缓冲器对象,分别用于两个transform feedback对象,对于这两个对象我们将进行一系列相同的操作(见下文)。

开始我们先使用glBindTransformFeedback()函数将一个transform feedback对象绑定到GL_TRANSFORM_FEEDBACK目标上,这样该transform feedback对象就变成当前对象,下面和transform feedback相关的操作就都是针对当前对象的。然后将对应的缓冲器对象绑定到GL_ARRAY_BUFFER上,使其成为一个常规的顶点缓冲器,并加载粒子数组的数据内容到缓冲器里面。最后我们绑定相应的缓冲器对象到GL_TRANSFORM_FEEDBACK_BUFFER目标上面,并定义缓冲器索引值为0,使该缓冲器成为一个索引位置为0的transform feedback缓冲器。事实上我们可以将多个缓冲器绑定到不同的索引位置上,这样图元可以输入到不同的缓冲器中,这里我们只需要一个缓冲器。现在我们就有了两个transform feedback对象和对应的两个缓冲器对象,两个缓冲器对象既可以作为顶点缓冲器也可以作为transform feedback缓冲器。

InitParticleSystem()函数剩下的部分就不需要再重复解释了,并没有什么新内容,我们只需简单初始化两个着色器对象(ParticleSystem类的成员),并设置其中一些静态状态,以及加载将要贴到粒子上的纹理。

(particle_system.cpp:124)

void ParticleSystem::Render(int DeltaTimeMillis, const Matrix4f& VP, const Vector3f& CameraPos)
{
    m_time += DeltaTimeMillis;

    UpdateParticles(DeltaTimeMillis);

    RenderParticles(VP, CameraPos);

    m_currVB = m_currTFB;
    m_currTFB = (m_currTFB + 1) & 0x1;
}

这是ParticleSystem类的主渲染函数,负责更新全局计时器,并在两个缓存之间进行切换(’m_currVB’是当前顶点缓冲器索引初始化为0,而’m_currTFB’是当前transform feedback缓冲器初始化为1)。这个函数的主要作用是调用两个更新粒子属性的私有方法并进行渲染。下面看如何更新粒子。

(particle_system.cpp:137)

void ParticleSystem::UpdateParticles(int DeltaTimeMillis)
{
    m_updateTechnique.Enable();
    m_updateTechnique.SetTime(m_time);
    m_updateTechnique.SetDeltaTimeMillis(DeltaTimeMillis);

    m_randomTexture.Bind(RANDOM_TEXTURE_UNIT);
    
开始先启用相应的着色器并设置其中的一些动态状态。着色器要知道从上一帧到这一帧的时间间隔,因为在计算粒子的位移公式中需要用到这个参数,另外还需要一个全局的时间参数作为随机数种子来访问随机纹理。我们声明一个GL_TEXTURE3纹理单元来绑定随机纹理。这个随机纹理是用来为产生的粒子提供运动方向的,而不是提供颜色信息(后面会介绍纹理是如何创建的)。

    glEnable(GL_RASTERIZER_DISCARD);
    
这个个函数回调用到了我们之前没有接触过的东西。由于该渲染回调到该函数的唯一目的就是为了更新transform feedback缓冲器,然后我们想截断图元传递流,并阻止它们经光栅化后显示到屏幕上。阻止的这些渲染操作之后将会在另一个渲染回调中再执行。使用GL_RASTERIZER_DISCARD标志作为参数调用glEnable()函数,告诉渲染管线在transform feedback可选阶段之后和到达光栅器前抛弃所有的图元。

    glBindBuffer(GL_ARRAY_BUFFER, m_particleBuffer[m_currVB]); 
    glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, m_transformFeedback[m_currTFB]);
    
接下来的两个函数用来切换我们创建的两个缓冲器。’m_currVB’(0或1)作为顶点缓存数组的一个索引,并且我们将这个缓存绑定到GL_ARRAY_BUFFER上作为输入。’m_currTFB’(总是和’m_currVB’相反)作为transform feedback对象数组的一个索引并且我们将其绑定到GL_TRANSFORM_FEEDBACK目标上,使其成为当前的 transform feedback(连同附着在其上的状态——实际的缓存)。

    glEnableVertexAttribArray(0);
    glEnableVertexAttribArray(1);
    glEnableVertexAttribArray(2);
    glEnableVertexAttribArray(3);

    glVertexAttribPointer(0,1,GL_FLOAT,GL_FALSE,sizeof(Particle),0); // type
    glVertexAttribPointer(1,3,GL_FLOAT,GL_FALSE,sizeof(Particle),(const GLvoid*)4); // position
    glVertexAttribPointer(2,3,GL_FLOAT,GL_FALSE,sizeof(Particle),(const GLvoid*)16); // velocity
    glVertexAttribPointer(3,1,GL_FLOAT,GL_FALSE,sizeof(Particle),(const GLvoid*)28); // lifetime

上面这几个函数我们之前都使用过,都是根据顶点缓冲区中的数据设置顶点属性。后面会看到我们如何保证输入的结构和输出结构保持一致的。

    glBeginTransformFeedback(GL_POINTS);
    
The real fun starts here. glBeginTransformFeedback() makes transform feedback active. All the draw calls after that, and until glEndTransformFeedback() is called, redirect their output to the transform feedback buffer according to the currently bound transform feedback object. This function also takes a topology parameter. The way transform feedback works is that only complete primitives (i.e. lists) can be written into the buffer. This means that if you draw four vertices in triangle strip topology or six vertices in triangle list topology, you end up with six vertices (two triangles) in the feedback buffer in both cases. The available topologies to this function are therefore:

GL_POINTS - the draw call topology must also be GL_POINTS.
GL_LINES - the draw call topology must be GL_LINES, GL_LINE_LOOP or GL_LINE_STRIP.
GL_TRIANGLES - the draw call topology must be GL_TRIANGLES, GL_TRIANGLE_STRIP or GL_TRIANGLE_FAN.

    if (m_isFirst) {
        glDrawArrays(GL_POINTS, 0, 1);
        m_isFirst = false;
    }
    else {
        glDrawTransformFeedback(GL_POINTS, m_transformFeedback[m_currVB]);
    } 

As described earlier, we have no way of knowing how many particles end up in the buffer and transform feedback supports this. Since we generate and destroy particles based on the launcher frequency and each particle lifetime, we cannot tell the draw call how many particles to process. This is all true - except for the very first draw. In this case we know that our vertex buffer contains only the launcher and the "system" doesn't have any record of previous transform feedback activity so it cannot tell the number of particles on its own. This is why the first draw must be handled explicitly using a standard glDrawArrays() function of a single point. The remaining draw calls will be done using glDrawTransformFeedback(). This function doesn't need to be told how many vertices to process. It simply checks the input buffer and draws all the vertices that have been previously written into it (when it was bound as a transform feedback buffer). Note that whenever we bind a transform feedback object the number of vertices in the buffer becomes zero because we called glBindBufferBase() on that buffer while the transform feedback object was originally bound (see the initialization part) with the parameter zero as the offset. OpenGL remembers that so we don't need to call glBindBufferBase() again. It simply happens behind the scenes when the transform feedback object is bound.

glDrawTransformFeedback() takes two parameters. The first one is the topology. The second one is the transform feedback object to which the current vertex buffer is attached. Remember that the currently bound transform feedback object is m_transformFeedback[m_currTFB]. This is the target of the draw call. The number of vertices to process as input comes from the transform feedback object which was bound as a target in the previous time we went through ParticleSystem::UpdateParticles(). If this is confusing, simply remember that when we draw into transform feedback object #1 we want to take the number of vertices to draw from transform feedback #0 and vice versa. Today's input is tomorrow's output.

    glEndTransformFeedback();
    
Every call to glBeginTransformFeedback() must be paired with glEndTransformFeedback(). If you miss that things will break pretty quick.

    glDisableVertexAttribArray(0);
    glDisableVertexAttribArray(1);
    glDisableVertexAttribArray(2);
    glDisableVertexAttribArray(3);
}

The end of the function is standard. When we get to this point all the particles have been updated. Let’s see how to render them in their new positions.

(particle_system.cpp:177)

void ParticleSystem::RenderParticles(const Matrix4f& VP, const Vector3f& CameraPos)
{
    m_billboardTechnique.Enable();
    m_billboardTechnique.SetCameraPosition(CameraPos);
    m_billboardTechnique.SetVP(VP);
    m_pTexture->Bind(COLOR_TEXTURE_UNIT);
    
We start the actual rendering by enabling the billboarding technique and setting some state into it. Each particle will be extended into a quad and the texture that we bind here will be mapped on its face.

    glDisable(GL_RASTERIZER_DISCARD);
    
Rasterization was disabled while we were writing into the feedback buffer. We enable it by disabling the GL_RASTERIZER_DISCARD feature.

    glBindBuffer(GL_ARRAY_BUFFER, m_particleBuffer[m_currTFB]);
    
When we wrote into the transform feedback buffer we bound m_transformFeedback[m_currTFB] as the transform feedback object (the target). That object has m_particleBuffer[m_currTFB] as the attached vertex buffer. We now bind this buffer to provide the input vertices for rendering.

    glEnableVertexAttribArray(0);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Particle), (const GLvoid*)4); // position

    glDrawTransformFeedback(GL_POINTS, m_transformFeedback[m_currTFB]);

    glDisableVertexAttribArray(0);
}

The particle in the transform feedback buffer has four attributes. In order to render it we only need position so only a single attribute is enabled. Make sure that the stride (distance between that attribute in two consecutive vertices) is set to sizeof(Particle) to accomodate the three attributes that we ignore. Failing to do so will result in a corrupted image.

In order to draw we use glDrawTransformFeedback() again. The second parameter is the transform feedback object that matches the input vertex buffer. This object “knows” how many vertices to draw.

(ps_update_technique.cpp:151)

bool PSUpdateTechnique::Init()
{
    if (!Technique::Init()) {
        return false;
    }

    if (!AddShader(GL_VERTEX_SHADER, pVS)) {
        return false;
    }

    if (!AddShader(GL_GEOMETRY_SHADER, pGS)) {
        return false;
    }

    const GLchar* Varyings[4]; 
    Varyings[0] = "Type1";
    Varyings[1] = "Position1";
    Varyings[2] = "Velocity1"; 
    Varyings[3] = "Age1";

    glTransformFeedbackVaryings(m_shaderProg, 4, Varyings, GL_INTERLEAVED_ATTRIBS); 

    if (!Finalize()) {
        return false;
    }

    m_deltaTimeMillisLocation = GetUniformLocation("gDeltaTimeMillis");
    m_randomTextureLocation = GetUniformLocation("gRandomTexture");
    m_timeLocation = GetUniformLocation("gTime");
    m_launcherLifetimeLocation = GetUniformLocation("gLauncherLifetime");
    m_shellLifetimeLocation = GetUniformLocation("gShellLifetime");
    m_secondaryShellLifetimeLocation = GetUniformLocation("gSecondaryShellLifetime");

    if (m_deltaTimeMillisLocation == INVALID_UNIFORM_LOCATION ||
        m_timeLocation == INVALID_UNIFORM_LOCATION ||
        m_randomTextureLocation == INVALID_UNIFORM_LOCATION) {
        m_launcherLifetimeLocation == INVALID_UNIFORM_LOCATION ||
        m_shellLifetimeLocation == INVALID_UNIFORM_LOCATION ||
        m_secondaryShellLifetimeLocation == INVALID_UNIFORM_LOCATION) {
        return false;
    }

    return true;
}

You now understand the mechanics of creating a transform feedback object, attaching a buffer to it and rendering into it. But there is still the question of what exactly goes into the feedback buffer? Is it the entire vertex? Can we specify only a subset of the attributes and what is the order between them? The answer to these questions lies in the code in boldface above. This function initializes the PSUpdateTechnique which handles the update of the particles. We use it within the scope of glBeginTransformFeedback() and glEndTransformFeedback(). To specify the attributes that go into the buffer we have to call glTransformFeedbackVaryings() before the technique program is linked. This function takes four parameters: the program handle, an array of strings with the name of the attributes, the number of strings in the array and either GL_INTERLEAVED_ATTRIBS or GL_SEPARATE_ATTRIBS. The strings in the array must contain names of output attributes from the last shader before the FS (either VS or GS). When transform feedback is active these attributes will be written into the buffer per vertex. The order will match the order inside the array. The last parameter to glTransformFeedbackVaryings() tells OpenGL either to write all the attributes as a single structure into a single buffer (GL_INTERLEAVED_ATTRIBS). Or to dedicate a single buffer for each attribute (GL_SEPARATE_ATTRIBS). If you use GL_INTERLEAVED_ATTRIBS you can only have a single transform feedback buffer bound (as we do). If you use GL_SEPARATE_ATTRIBS you will need to bind a different buffer to each slot (according to the number of attributes). Remember that the slot is specified as the second parameter to glBindBufferBase(). In addition, you are limited to no more than GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS attribute slots (which is usually 4).

Other than glTransformFeedbackVaryings() the initialization stuff is pretty standard. But note that the FS is missing from it. If we disable rasterization when we update the particles we don’t need a FS…

(ps_update.vs)

#version 330

layout (location = 0) in float Type;
layout (location = 1) in vec3 Position;
layout (location = 2) in vec3 Velocity;
layout (location = 3) in float Age;

out float Type0;
out vec3 Position0;
out vec3 Velocity0;
out float Age0;

void main()
{
    Type0 = Type;
    Position0 = Position;
    Velocity0 = Velocity;
    Age0 = Age;
}

This is the VS of the particle update technique and as you can see - it is very simple. All it does is pass through the vertices to the GS (where the real action takes place).

(ps_update.gs)

#version 330

layout(points) in;
layout(points) out;
layout(max_vertices = 30) out;

in float Type0[];
in vec3 Position0[];
in vec3 Velocity0[];
in float Age0[];

out float Type1;
out vec3 Position1;
out vec3 Velocity1;
out float Age1;

uniform float gDeltaTimeMillis;
uniform float gTime;
uniform sampler1D gRandomTexture;
uniform float gLauncherLifetime;
uniform float gShellLifetime;
uniform float gSecondaryShellLifetime;

#define PARTICLE_TYPE_LAUNCHER 0.0f
#define PARTICLE_TYPE_SHELL 1.0f
#define PARTICLE_TYPE_SECONDARY_SHELL 2.0f

That's the start of the GS in the particle update technique with all the declarations and definitions that we will need. We are going to get points as input and provide points as output. All the attributes we will get from the VS will also end up in the transform feedback buffer (after having gone through some processing). There are a few uniform variables that we depend on and we also enable the application to configure the frequency of the launcher and the lifetime of the shell and the secondary shell (the launcher generates one shell according to its frequency and the shell explodes to secondary shells after its configured lifetime is expired).

vec3 GetRandomDir(float TexCoord)
{
    vec3 Dir = texture(gRandomTexture, TexCoord).xyz;
    Dir -= vec3(0.5, 0.5, 0.5);
    return Dir;
}

This is a utility function that we will use to generate a random direction for the shells. The directions are stored in a 1D texture whose elements are 3D vectors (floating point). We will later see how we populate the texture with random vectors. This function simply takes a floating point value and uses it to sample from the texture. Since all the values in the texture are in the [0.0-1.0] range we substract the vector (0.5,0.5,0.5) from the sampled result in order to move the values into the [-0.5 - 0.5] range. This allows the particles to fly in all directions.

void main()
{
    float Age = Age0[0] + gDeltaTimeMillis;

    if (Type0[0] == PARTICLE_TYPE_LAUNCHER) {
        if (Age >= gLauncherLifetime) {
            Type1 = PARTICLE_TYPE_SHELL;
            Position1 = Position0[0];
            vec3 Dir = GetRandomDir(gTime/1000.0);
            Dir.y = max(Dir.y, 0.5);
            Velocity1 = normalize(Dir) / 20.0;
            Age1 = 0.0;
            EmitVertex();
            EndPrimitive();
            Age = 0.0;
        }

        Type1 = PARTICLE_TYPE_LAUNCHER;
        Position1 = Position0[0];
        Velocity1 = Velocity0[0];
        Age1 = Age;
        EmitVertex();
        EndPrimitive();
    }
    
The main function of the GS contains the processing of the particles. We start by updating the age of the particle at hand and then we branch according to its type. The code above handles the case of the launcher particle. If the launcher's lifetime has expired we generate a shell particle and emit it into the transform feedback buffer. The shell gets the position of the launcher as a starting point and a random direction from the random texture. We use the global time as a pseudo random seed (not really random but the results are good enough). We make sure the minimum Y value of the direction is 0.5 so that the shell is emitted in the general direction of the sky. The direction vector is then normalized and divided by 20 to provide the velocity vector (you may need to tune that for your system). The age of the new particle is ofcourse zero and we also reset the age of the launcher to get that process started again. In addition, we always output the launcher itself back into the buffer (else no more particles will be created).

    else {
        float DeltaTimeSecs = gDeltaTimeMillis / 1000.0f;
        float t1 = Age0[0] / 1000.0;
        float t2 = Age / 1000.0;
        vec3 DeltaP = DeltaTimeSecs * Velocity0[0];
        vec3 DeltaV = vec3(DeltaTimeSecs) * (0.0, -9.81, 0.0);
        
Before we start handling the shell and the secondary shell we setup a few variables that are common to both. The delta time is translated from milliseconds to seconds. We translate the old age of the particle (t1) and the new age (t2) to seconds as well. The change in the position is calculated according to the equation 'position = time * velocity'. Finally we calculate the change in velocity by multiplying the delta time by the gravity vector. The particle gains a velocity vector when it is born, but after that the only force that affects it (ignoring wind, etc) is gravity. The speed of a falling object on earth increases by 9.81 meters per second for every second. Since the direction is downwards we get a negative Y component and zero on the X and Z. We use a bit of a simplified calculation here but it serves its purpose.

        if (Type0[0] == PARTICLE_TYPE_SHELL) {
            if (Age < gShellLifetime) {
                Type1 = PARTICLE_TYPE_SHELL;
                Position1 = Position0[0] + DeltaP;
                Velocity1 = Velocity0[0] + DeltaV;
                Age1 = Age;
                EmitVertex();
                EndPrimitive();
            }
            else {
                for (int i = 0 ; i < 10 ; i++) {
                    Type1 = PARTICLE_TYPE_SECONDARY_SHELL;
                    Position1 = Position0[0];
                    vec3 Dir = GetRandomDir((gTime + i)/1000.0);
                    Velocity1 = normalize(Dir) / 20.0;
                    Age1 = 0.0f;
                    EmitVertex();
                    EndPrimitive();
                }
            }
        }
        
We now take care of the shell. As long as the age of this particle hasn't reached its configured lifetime it remains in the system and we only update its position and velocity based on the deltas we calculated earlier. Once it reaches the end of its life it is destroyed and instead we generate 10 secondary particles and emit them into the buffer. They all gain the position of their parent shell but each gets its own random velocity vector. In the case of the secondary shell we don't limit the direction so the explosion looks real.

        else {
            if (Age < gSecondaryShellLifetime) {
                Type1 = PARTICLE_TYPE_SECONDARY_SHELL;
                Position1 = Position0[0] + DeltaP;
                Velocity1 = Velocity0[0] + DeltaV;
                Age1 = Age;
                EmitVertex();
                EndPrimitive();
            }
        }
    }
}

Handling of the secondary shell is similar to the shell, except that when it reaches the end of its life it simply dies and no new particle is generated.

(random_texture.cpp:37)

bool RandomTexture::InitRandomTexture(unsigned int Size)
{
    Vector3f* pRandomData = new Vector3f[Size];

    for (unsigned int i = 0 ; i < Size ; i++) {
        pRandomData[i].x = RandomFloat();
        pRandomData[i].y = RandomFloat();
        pRandomData[i].z = RandomFloat();
    }

    glGenTextures(1, &m_textureObj);
    glBindTexture(GL_TEXTURE_1D, m_textureObj);
    glTexImage1D(GL_TEXTURE_1D, 0, GL_RGB, Size, 0.0f, GL_RGB, GL_FLOAT, pRandomData);
    glTexParameterf(GL_TEXTURE_1D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_1D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_1D, GL_TEXTURE_WRAP_S, GL_REPEAT); 

    delete [] pRandomData;

    return GLCheckError();
}

The RandomTexture class is a useful tool that can provide random data from within the shaders. It is a 1D texture with the GL_RGB internal format and floating point data type. This means that every element is a vector of 3 floating point values. Note that we set the wrap mode to GL_REPEAT. This allows us to use any texture coordinate to access the texture. If the texture coordinate is more than 1.0 it is simply wrapped around so it always retrieves a valid value. In this series of tutorials the texture unit 3 will be dedicated for random textures. You can see the setup of the texture units in the header file engine_common.h.

一步步学OpenGL(27) -《公告牌技术与几何着色器》

教程 27

公告牌技术与几何着色器

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial27/tutorial27.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

从最初的一系列教程我们已经应用过了顶点着色器和片段着色器,但事实上我们还忽略了一个非常重要的着色阶段,叫做几何着色器(GS)。几何着色器在微软的DirectX 10之后被引入,之后也加入到了核心的OpenGL 3.2中。顶点着色器中是按照顶点一个一个执行的,片段着色器则是一个像素一个像素执行,而几何着色器是以图元为单位执行,这意味着在我们绘制三角形时,每次调用几何着色器接收到的就是一个三角形,而绘制直线每次调用收到的就是一条直线,等等。这就给几何着色器提供了一个看待模型的独特的角度,开发者可以知道顶点和顶点之间拓扑关系,从而可以基于此开发一些新的技术。

顶点着色器总是以一个顶点作为输入,并对应一个顶点作输出(不可以自行增加或减少顶点),而几何着色器却有着特殊的功能,它可以改变经过它的图元,这种改变包括:

  • 改变新传递进来的图元的拓扑结构。顶点着色器可以接收任何拓扑类型的图元,但只能输出顶点列表(point lists)、折线(line strip)和三角带(triangle strips);

  • 顶点着色器接受一个图元作为输入,在处理过程中它可以将这个图元全部丢弃或者输出一个或更多的图元(也就是说它可以产生比它得到的更多或更少的顶点)。这个能力被叫做几何增长(growing geometry)。这一章我们将会利用几何着色器的这种能力。

顶点着色器是可选择的。如果我们编译程序的时候不使用顶点着色器,图元会直接的从顶点着色器进入片元着色器。这就是为什么我们之前并没有使用顶点着色器,却可以直接跳过该阶段正常绘制出图形。

三角形列表中的三角形图元是以每三个顶点一组构建的,例如,0-2前三个顶点构建起第一个三角形,3-5三个顶点构建起第二个三角形,以此类推。为了计算已有顶点所组成的三角形个数,只要用顶点数除以3即可(多余的顶点直接抛弃)。但事实上,使用三角形带构建更有效,不需要每个三角形都用专门的三个顶点构建,而是在使用三个顶点将第一个三角形构建完成后,重复利用其中的两个顶点,然后再添加一个顶点即可构建第二个三角形。例如0-2三个顶点构建起一个三角形后,再添加一个顶点3,1-3三个顶点构成第二个三角形,这样0-3四个顶点即可紧密拼接构建起两个三角形,以此类推,再添加顶点4,2-4三个顶点又构成一个三角形等等。也就是说,在第一个三角形使用三个顶点构建完成后,每天添加一个顶点即可再构成一个三角形。这里有一个例子如下图:

可以看到,三角带中7个三角形只用了9个顶点,要是在三角形列表中,9个顶点就只能构建3个三角形。

三角带有一个有关三角形内部环绕顺序的重要性质:奇数三角形的环绕顺序是反向的。这就意味着如下的顺序:[0,1,2],[1,3,2], [2,3,4], [3,5,4],以此类推。下面的图片显示了这个顺序:

了解了几何着色器之后,现在看如何利用几何着色器来实现一种非常有用的热门技术:公告板技术(billboarding)。公告板是一个始终朝向相机的四边形,当相机在场景中转动的时候,公告板也会随着相机转动保证相机方向向量始终垂直于公告板的正面。这个和现实中公路边的公告板类似,公告板的放置方向会让尽可能多路过的开车司机看到。有了这种面向相机的四边形之后,我们就很容易将怪物角色、树木等任何场景中重复性高、数量多的物体以纹理贴图的形式直接贴到四边形公告板上(而不需要复杂的计算和渲染实际的3d模型),始终朝向相机。公告板常常用来创建需要大量树木的森林效果,由于公告板始终朝向相机,玩家会误以为看到的物体是有实际深度的,而事实上单纯就是个平面而已。每一个公告板只需要4个顶点,因此比起使用大量实际的3d模型代价就小得多了。

在这个教程中,我们创建一个顶点缓冲器,并为公告板存放顶点的世界空间坐标,每一个坐标就是一个单独的3维坐标点。我们会将这些顶点坐标传送到几何着色器,然后构建相应的四边形作为公告板。这意味着几何着色器会输入顶点列表,然后输出三角形带。利用三角带的优点我们可以使用四个顶点创建出一个四边形。

几何着色器会负责调整四边形始终朝向相机,并为每一个输出的顶点附加纹理坐标,这样片段着色器只需要直接从纹理上采样就可以得到最终的颜色信息。

现在看怎样让公告板总是朝向相机。在下图中黑点表示相机,红点表示公告板的位置。虽然图中位置看上去好像在一个平面上,但实际两个点都在世界空间下,世界空间中任意两个点都是可能的。

这里创建一个从公告板到相机的向量:

然后添加一个向量(0,1,0):

对这两个向量做叉乘,结果得到一个垂直于这两个向量所在平面的向量,然后就要沿着这个结果向量的方向来扩展创建我们的四边形,保证四边形平面和相机朝向垂直,这样才符合我们想要的结果。同样的场景下我们可以得到下面这样(黄色向量是叉乘的结果向量):

一个容易让开发者疑惑的事情是关于向量做叉积的顺序问题(A叉积B,还是B叉积A?),两种情况会得到两个反向的结果向量。事先知道具体的结果向量是很关键的,因为我们要这样输出顶点,使从相机的视角看时三角形组成的四边形呈顺时针方向。这里要用到左手法则了:

如果站在公告板的位置(红点),食指指向相机,中指指向上方的天空,然后你的大拇指将会沿着“食指”和“中指”叉乘的结果的方向(这里剩下的两个手指保持握紧)。此教程中,我们将叉乘的结果称为“右”向量,因为从相机位置看我们的手,‘右’向量是指向右侧的。反过来,“中指”叉乘“食指”又可以产生一个反向的“左”向量。(这里我们使用左手定则的原因是因为我们使用的是左手坐标系(Z轴指向屏幕内))。在右手坐标系中情况就相反了,那样就该选用右手坐标系了。

源代码详解

(billboard_list.h:27)

class BillboardList
{
public:
    BillboardList();
    ~BillboardList();

    bool Init(const std::string& TexFilename);

    void Render(const Matrix4f& VP, const Vector3f& CameraPos);

private:
    void CreatePositionBuffer();

    GLuint m_VB;
    Texture* m_pTexture;
    BillboardTechnique m_technique;
};

BillboardList类封装了创建公告板需要的所有东西,初始化函数Init()的参数为一个文件名,文件就是那个作为纹理贴图贴到公告板上的图像。Render()渲染函数在主渲染循环中被调用,负责设置状态和渲染公告板。这个函数需要两个参数:一个是视图和投影组合矩阵,一个是相机的世界坐标位置。由于公告板的位置也是定义在世界空间中的,所以我们才直接到了视图和投影阶段,跳过世界空间变换的部分。这个类有三个私有属性:一个存储公告板位置的顶点缓冲器,一个指向公告板纹理贴图的指针,和一个包含相关着色器的BillboardTechnique公告板类。

(billboard_list.cpp:80)

void BillboardList::Render(const Matrix4f& VP, const Vector3f& CameraPos)
{
    m_technique.Enable();
    m_technique.SetVP(VP);
    m_technique.SetCameraPosition(CameraPos);

    m_pTexture->Bind(COLOR_TEXTURE_UNIT);

    glEnableVertexAttribArray(0);

    glBindBuffer(GL_ARRAY_BUFFER, m_VB);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vector3f), 0); // position 

    glDrawArrays(GL_POINTS, 0, NUM_ROWS * NUM_COLUMNS);

    glDisableVertexAttribArray(0);
}

这个函数启用了BillboardTechnique公告板类,并设置了OpenGL中一些必要的状态,绘制顶点且这些顶点之后会在几何着色器阶段转化成四边形面。在这个Demo中,公告板位置是按照严格的行列顺序排列的,因此我们可以行列数相乘得到顶点的数量。需要注意,我们在绘制的时候使用的绘制模式为点模式(GL_POINTS),在几何着色器中要与其对应。

(billboard_technique.h:24)

class BillboardTechnique : public Technique
{
public:

    BillboardTechnique();

    virtual bool Init();

    void SetVP(const Matrix4f& VP);
    void SetCameraPosition(const Vector3f& Pos);
    void SetColorTextureUnit(unsigned int TextureUnit);

private:

    GLuint m_VPLocation;
    GLuint m_cameraPosLocation;
    GLuint m_colorMapLocation;
};

这里是BillboardTechnique的类接口,它只需要三个参数来完成下面的任务:视图和投影组合矩阵、相机世界空间位置和与公告板绑定的纹理单元的数量。

(billboard.vs)

#version 330


layout (location = 0) in vec3 Position;

void main()
{
    gl_Position = vec4(Position, 1.0);
}

这是公告板类的顶点着色器,由于主要工作都将在几何着色器中完成,因此在这里顶点着色器就不要太简单了哈哈。顶点缓冲器只包含顶点坐标向量,而且这些坐标已经在世界空间中定义了,所以可以直接将它们传给几何着色器,即可。

(billboard.gs:1)

#version 330

layout (points) in;
layout (triangle_strip) out;
layout (max_vertices = 4) out;

公告板技术的核心就在几何着色器了,我们分解开一步步来看。开始我们先使用‘layout’关键字声明一些全局缓冲器。我们要先告诉渲染管线输入来的参数结构是点列表,输出的是三角带,并且说明输出的顶点个数最多为4个。这些关键词也会提示图形驱动器从几何着色器输出顶点的最大个数,提前知道顶点个数上限可以给驱动器机会来优化几何着色器在某些特定情况下的动作。我们知道对于每一个输入的顶点要输出的是一个扩展的四边形,因此我们设置最大顶点数为4。

(billboard.gs:7)

uniform mat4 gVP;
uniform vec3 gCameraPos;

out vec2 TexCoord;

几何着色器得到了世界空间坐标,因此他只需要一个视图和投影组合变换矩阵即可。另外还需要知道相机的位置来计算如何让公告板始终朝向它。几何着色器为片段着色器创建出了纹理坐标,因此我们也要声明纹理坐标变量。

(billboard.gs:12)

void main()
{
    vec3 Pos = gl_in[0].gl_Position.xyz;

上面一行代码是针对几何着色器独有的。由于在几何着色器中执行的是一个完整的图元,因此事实上我们可以访问组成图元的每一个顶点,这个通过内置的‘gl_in’变量实现。这个变量是一个结构体数组,每个结构体都包含了写入到顶点着色器gl_Position中的位置信息。为了访问顶点信息,我们可以使用要访问的顶点在图元中的索引找到。在这个特定的例子中,参数的输入结构为点列表,所以每个图元只有一个可访问的单独的点,可以使用’gl_in[0]’获取它。如果输入结构是个三角形,我们可能还会使用’gl_in[1]’和’gl_in[2]’来访问其他点。我么只需要使用顶点位置向量的前三个xyz分量,通过本地变量’.xyz’提取。

    vec3 toCamera = normalize(gCameraPos - Pos);
    vec3 up = vec3(0.0, 1.0, 0.0);
    vec3 right = cross(toCamera, up);

这里我们利用文章开始背景部分结尾的原理实现让公告板朝向相机。我们将当前公告板的位置点到相机位置的向量和垂直向上的方向向量做叉积,得到从相机看公告板视角的‘右’向量,然后我们要使用这个向量围着公告板的位置扩展一个四边形面。

    Pos -= (right * 0.5);
    gl_Position = gVP * vec4(Pos, 1.0);
    TexCoord = vec2(0.0, 0.0);
    EmitVertex();

    Pos.y += 1.0;
    gl_Position = gVP * vec4(Pos, 1.0);
    TexCoord = vec2(0.0, 1.0);
    EmitVertex();

    Pos.y -= 1.0;
    Pos += right;
    gl_Position = gVP * vec4(Pos, 1.0);
    TexCoord = vec2(1.0, 0.0);
    EmitVertex();

    Pos.y += 1.0;
    gl_Position = gVP * vec4(Pos, 1.0);
    TexCoord = vec2(1.0, 1.0);
    EmitVertex();

    EndPrimitive();
}

顶点缓冲器中的点可以被认为是四边形底边的中点,我们要从中点创建两个面朝相机的正面三角形。开始先用中点减去‘右’向量的一半,从而得到四边形的左下角。然后通过乘以视图和投影组合变换矩阵计算该点在裁剪空间的位置,并设置该点的纹理坐标为(0,0),便于将整个纹理完整贴到这个平面上。为了将新产生的顶点传递到管线的下一个阶段,我们需要调用内置的EmitVertex()函数。这个函数调用后,我们之前写入gl_Position的数据就无效了,因此我们要为其设置新值。和左下角点产生方法类似的,我们继续创建出四边形左上角和右下角的点,这样三个点就构建出了第一个正面三角形。由于几何着色器的输出是三角带,之后我们只需要另外一个顶点即可构建第二个三角形,使用前面三角形的后两个顶点(四边形的对角线)和新顶点构建。第四个新顶点也是最后一个顶点,即四边形的右上角。结束三角带的构建要调用内置的EndPrimitive()函数。

(billboard.fs)

#version 330

uniform sampler2D gColorMap;

in vec2 TexCoord;
out vec4 FragColor;

void main()
{
    FragColor = texture2D(gColorMap, TexCoord);

    if (FragColor.r == 0 && FragColor.g == 0 && FragColor.b == 0) {
        discard;
    }
}

片段着色器很简单,它的主要工作是使用几何着色器创建的纹理坐标进行纹理采样。这里有一个新特性:内置的关键字’discard’用于在某些情况下将某些像素片元完全丢弃。这个教程中我们用了Doom中地狱骑士的图片,展示黑色背景下的怪物场景,但是直接贴上整张图片会有黑色的背景,就是说公告板内容比怪物要大,怪物背景不是透明的,我们不希望这样,因此我们可以检测文素的颜色,如果是黑色就直接抛弃该像素,这样就会只显示怪物了。可以尝试注释掉’discard’语句看效果有什么不同。

一步步学OpenGL(26) -《法线贴图》

教程 26

法线贴图

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial26/tutorial26.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

之前的我们的光线着色器类已经可以达到很不错的效果了,光线效果通过插值计算遍布到整个模型表面,使整个场景看上去比较真实,但这个效果还可以进行更好的优化。事实上,有时插值计算反而会影响场景的真实性,尤其是用材质来表现一些凹凸不平的纹理的时候,插值会让模型表面看上去太平坦了,例如下图的例子: 左边的图片效果明显比右边的要好,左边的更好的表现了石头纹理的崎岖不平,而右边的则看上去太平滑。左图的效果是使用一种叫做法线贴图(又称凹凸贴图)的技术渲染的,这边教程的主题就是这种技术。

之前我们是在三角形的三个顶点法向量之间进行平滑插值来得到三角形上每个点的法向量,这样表面会比较光滑,而法线贴图的思想是直接从‘法线贴图’上进行采样得到对应的法线方向,这样更加接近现实,因为绝大多数物体表面(尤其是游戏中)并不是那样光滑的,而是按照凹凸表面各个位置的不同方向来反射照过来的光线,不会像我们插值计算的那样平滑一致。对于每一张纹理贴图,那些表面上的所有法向量都是可以被计算并且存储在我们所谓的法线贴图里的。在片段着色器阶段进行光照计算的时候,每个像素的特定法线也是根据纹理坐标采样来获取使用。下面的图片展示了法线贴图和常规贴图中法线的不同:

现在我们有了我们的法线贴图,里面存储了真实的或者说至少是接近的表面法线。那我们可以简单的直接用它吗?不是的,考虑一下上面的带有砖块纹理的立方体,六个面贴有相同的纹理贴图,但是六张相同贴图的方向并不一样,所以对应的法向量在任意光线的照射下也应该是不一样的。如果我们直接使用同一张法线纹理,不做相应的修改,就会得到错误的结果,因为相同的法线应用到六个方向不同的表面上是不可能正确的!例如,上表面上的点的的法向量通常为(0,1,0),即使是非常凹凸不平的表面,对应底部表面上的点的法向量也都是(0,1,0)。重点是法向量是在它们私有的坐标空间中定义的,因此必要进行变换,使它们可以在世界坐标空间中正确的参与光照计算。某种意义上说,这个概念和顶点法向量的变换方式类似,顶点法向量定义在物体本地坐标空间下,然后我们通过用世界矩阵变换把他们转换到世界空间。

首先这里要定义法线的本地坐标系,坐标系需要三个正交单位向量。由于法线是2D纹理的的一部分,而2D纹理有两个正交单位向量U和V,因此通常做法是将X分量对应到U轴,而Y分量对应到V轴。注意U是从左到右的,而V是从下往上的(那个坐标系的原点位于纹理的左下角)。Z分量则是垂直于纹理,垂直于X和Y轴的了。

现在法向量就可以根据那个坐标系定义了,并存储在纹理的RGB文素中。注意即使是在非常凹凸不平的表面,我们仍然认为法线的方向是从纹理朝外的。例如:Z分量主导的一个分量,X和Y分量只能起到让其略微倾斜的作用。将XYZ向量存储在RGB文素中会使得法线纹理像下图那样偏蓝色: 下面这几个是这张法线纹理顶行的五个文素值(从左到右):(136,102,248), (144,122,255), (141,145,253), (102, 168, 244) 和 (34,130,216)。这里Z分量的主导性很明显。

接下来我们需要做的是检查模型中所有的三角面,并且按照每个顶点的纹理坐标匹配其在法线纹理上的坐标的方式,将法线纹理映射到每个三角面上。例如,如果给出的三角形的纹理坐标是(0.5,0), (1, 0.5) 和 (0,1)。那么法线纹理会按如下的方式放置: 上图中左下角的坐标系代表了物体的本地坐标系。

除了纹理坐标,三个顶点还有代表他们位置的本地空间3d坐标。当我们将纹理贴图放到上面的三角形上面后,我们实际上是给贴图的UV坐标赋了本地空间下的对应的值。如果现在我们计算出在物体本地坐标空间UV的值(同时通过U和V的差积计算出对应的法向量),我们将可以得到一个将法向量从贴图变换到物体本地坐标空间的变换矩阵,那样就可以继而变换到世界坐标空间并且参与光照计算。通常U向量我们称之为Tangent,而V向量我们称之为Bitangent,其中我们需要推导的的变换矩阵称为TBN矩阵(Tangent-Bitangent-Normal)。这些TBN向量定义了一个叫做Tangent(或者说纹理)空间的坐标系。所以,法线纹理中的法向量是存储在Tangent/纹理空间中的。接下来我们将研究如何计算物体空间下U和V向量的值。

现在看图中一个更加一般化的三角形,三角形三个顶点位于位置P0,P1和P2,对应纹理坐标为(U0,V0), (U1,V1) and (U2,V2):

我们要找到物体本地空间下的向量T(表示tangent)和B(表示bitangent)。我们可以看到两个三角形边E1和E2可以写成T和B的线性组合:

也可以写成下面的形式:

现在可以很容易的转换成矩阵公式的形式:

现在想把矩阵转换到等式的右边,为此可以两边乘以上面标红的矩阵的逆矩阵:

计算如下:

算出逆矩阵的值得到:

我们可以对网格中的每一个三角形执行上述过程,并且为每个三角形都计算出tangent向量和bitangent向量(对三角形的三个顶点来说这两个向量都是一样的)。通常的做法是为每一个顶点都保存一个tangent/bitangent值,每个顶点的tangent/bitangent值由共享这个顶点的所有三角面的平均tangent/bitangent值确定(这与顶点法线是一样的)。这样做的原因是使整个三角面的效果比较平滑,防止相邻三角面之间的不平滑过渡。这个坐标系空间的第三个分量——法线分量,是tangent和bitangent的叉乘积。这样Tangent-Bitangent-Normal三个向量就能作为纹理坐标空间的基向量,并且实现将法线由法线纹理空间到模型局部空间的转换。接下来需要做的就是将法线变换到世界坐标系之下,并使之参与光照计算。不过我们可以对此进行一点优化,即将Tangent-Bitangent-Normal坐标系变换到世界坐标系下来,这样我们就能直接将纹理中的法线变换到世界坐标系中去。

在这一节中我们需要做下面几件事:

  • 将tangent向量传入到顶点着色器中;
  • 将tangent向量变换到世界坐标系中并传入到片元着色器;
  • 在片元着色器中使用tangent向量和法线向量(都处于世界坐标系下)来计算出bitangent向量;
  • 通过tangent-bitangent-normal矩阵生成一个将法线信息变换到世界坐标系中的变换矩阵;
  • 从法线纹理中采样得到法线信息;
  • 通过使用上述的矩阵将法线信息变换到世界坐标系中;
  • 继续和往常一样进行光照计算。

在我们的代码中有一点需要格外强调,在像素层次上,我们的tangent-bitangent-normal实际上并不是真正的正交基(三个单位向量互相垂直)。造成这种情况的原因有两个:首先对于每个顶点的tangent向量和法线向量,我们是通过对共享此顶点的所有三角面求平均值得到的;其次我们在像素层面看到的tangent向量和法线向量是经过光栅器插值得到的结果。这使得我们的tangent-bitangnet-normal矩阵丧失了他们的“正交特性”。但是为了将法线信息从纹理坐标系变换到世界坐标系我们需要一个正交基。解决方案是使用Gram-Schmidt进行处理。这个方案能够将一组基向量转换成正交基。这个方案大致如下:从基向量中选取向量 ‘A’ 并对其规范化,之后选取基向量中的向量 ‘B’ 并将其分解成两个分向量(两个分向量的和为‘B’),其中一个分向量沿着向量‘A’的方向,另一个分量则垂直于‘A’向量。现在用这个垂直于‘A’向量的分量替换‘B’向量并且对其规范化。按照这样的方法对所有基向量进行处理。

这样的最终结果是,虽然我们并没有使用数学上正确的TBN向量,但我们实现了必要的平滑效果,避免三角形边界上的明显间隔。

源代码详解

(mesh.h:33)
struct Vertex
{
    Vector3f m_pos;
    Vector2f m_tex;
    Vector3f m_normal;
    Vector3f m_tangent;

    Vertex() {}

    Vertex( const Vector3f& pos, 
            const Vector2f& tex, 
            const Vector3f& normal, 
            const Vector3f& Tangent )
    {
        m_pos = pos;
        m_tex = tex;
        m_normal = normal;
        m_tangent = Tangent;
    }
};

这是我们新的的顶点结构体,增加了一个tangent向量。至于bitangent向量我们会在片元着色器中进行计算。需要注意的是切线空间的法线与普通的三角形法线是一样的(因为纹理与三角是平行的)。因此虽然顶点法线位于两个不同的坐标系之中但是他们实际上是一样的。

for (unsigned int i = 0 ; i < Indices.size() ; i += 3) {
    Vertex& v0 = Vertices[Indices[i]];
    Vertex& v1 = Vertices[Indices[i+1]];
    Vertex& v2 = Vertices[Indices[i+2]];

    Vector3f Edge1 = v1.m_pos - v0.m_pos;
    Vector3f Edge2 = v2.m_pos - v0.m_pos;

    float DeltaU1 = v1.m_tex.x - v0.m_tex.x;
    float DeltaV1 = v1.m_tex.y - v0.m_tex.y;
    float DeltaU2 = v2.m_tex.x - v0.m_tex.x;
    float DeltaV2 = v2.m_tex.y - v0.m_tex.y;

    float f = 1.0f / (DeltaU1 * DeltaV2 - DeltaU2 * DeltaV1);

    Vector3f Tangent, Bitangent;

    Tangent.x = f * (DeltaV2 * Edge1.x - DeltaV1 * Edge2.x);
    Tangent.y = f * (DeltaV2 * Edge1.y - DeltaV1 * Edge2.y);
    Tangent.z = f * (DeltaV2 * Edge1.z - DeltaV1 * Edge2.z);

    Bitangent.x = f * (-DeltaU2 * Edge1.x - DeltaU1 * Edge2.x);
    Bitangent.y = f * (-DeltaU2 * Edge1.y - DeltaU1 * Edge2.y);
    Bitangent.z = f * (-DeltaU2 * Edge1.z - DeltaU1 * Edge2.z);

    v0.m_tangent += Tangent;
    v1.m_tangent += Tangent;
    v2.m_tangent += Tangent;
}

for (unsigned int i = 0 ; i < Vertices.size() ; i++) {
    Vertices[i].m_tangent.Normalize();
}

这部分代码是计算tangent向量的算法的实现(在“背景”中所描述的算法)。它遍历索引数组,并通过索引在顶点数组中获取组成三角面的顶点向量。为了表示三角面的两条边,我们用第二个顶点和第三个顶点分别减去第一个顶点。同样的,我们对纹理坐标也进行相似的处理来获得VU向量,并计算两条边沿着U轴和V轴的增量。‘f’为一个因子,他是“背景”中得到的最后一个等式的等号右边出现的那个因子。一旦求得了‘f’,那么用这两个矩阵的结果乘上它即可分别得到在模型局部坐标系之下tangent和bitangent向量的表示。需要注意的是这里对 bitangent向量的计算只是为了整个算法的完整性,我们真正需要的是被存放到顶点数组中的tangent向量。最后一件事就是遍历顶点数组对tangent向量进行规范化。

现在我们应该已经完全理解了这个算法的理论和实现,但是本章中不会使用这段代码。Open Asset Import库已经为我们实现了这一功能,使我们能够很方便的得到tangent向量(无论如何了解它的实现是非常重要的,也许有一天你需要自己来实现它)。我们只需要在导入模型的时候定义一个tangent变量,之后我们便可以访问aiMesh类中的‘mTangents’数组,并从这里获取tangent向量。详细实现可以参看源码。

(mesh.cpp:195)
void Mesh::Render()
{
    ...
    glEnableVertexAttribArray(3);

    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
        ...
        glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)32);
    }
   	...
    glDisableVertexAttribArray(3);
}

由于顶点结构体经过了扩充,我们需要对Mesh类的渲染函数进行一些改动。这里我们启用了第四个顶点属性并且指定tangent属性的位置在距顶点开始32字节位置处(位于法线之后)。在函数最后第四个顶点属性被禁用。

(lighting.vs)
layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;
layout (location = 3) in vec3 Tangent;

uniform mat4 gWVP;
uniform mat4 gLightWVP;
uniform mat4 gWorld;

out vec4 LightSpacePos;
out vec2 TexCoord0;
out vec3 Normal0;
out vec3 WorldPos0;
out vec3 Tangent0;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    LightSpacePos = gLightWVP * vec4(Position, 1.0);
    TexCoord0 = TexCoord;
    Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
    Tangent0 = (gWorld * vec4(Tangent, 0.0)).xyz;
    WorldPos0 = (gWorld * vec4(Position, 1.0)).xyz;
}

这是经过修改之后的顶点着色器,没有什么大的修改,因为大部分改动都在片元着色器中。新增部分只有tangent向量传入,之后将其变换到世界坐标系中并输出到片元着色器中。

(lighting.fs:132)
vec3 CalcBumpedNormal()
{
    vec3 Normal = normalize(Normal0);
    vec3 Tangent = normalize(Tangent0);
    Tangent = normalize(Tangent - dot(Tangent, Normal) * Normal);
    vec3 Bitangent = cross(Tangent, Normal);
    vec3 BumpMapNormal = texture(gNormalMap, TexCoord0).xyz;
    BumpMapNormal = 2.0 * BumpMapNormal - vec3(1.0, 1.0, 1.0);
    vec3 NewNormal;
    mat3 TBN = mat3(Tangent, Bitangent, Normal);
    NewNormal = TBN * BumpMapNormal;
    NewNormal = normalize(NewNormal);
    return NewNormal;
}

void main()
{
    vec3 Normal = CalcBumpedNormal();
    ...

上面这段代码包含了片元着色器中的大部分改动,所有对法线的操作都被封装在CalcBumpedNormal()函数中。首先我们先对法线向量和 tangent向量进行规范化,第三行中的代码就是Gram-Schmidt处理的实现。dot(Tangent, Normal)求出了tangent向量投影到法线向量上的长度,将这个结果乘上法线向量即可得到tangent向量在沿着法线向量方向上的分量。之后我们用tangent向量减去它在法线方向上的分量即可得到其垂直于法线方向上的分量。这就是我们新的tangent向量(要记住对其进行规范化)。新的tangent向量和法线向量之间是我叉乘结果就是bitangent向量。之后我们从法线纹理中采样得到此片元的法线信息(位于切线/纹理空间)。‘gNormalMap’是一个新增加的sampler2D类型的一致变量,我们需要在绘制之前将法线纹理绑定到它上面。法线信息的存储方式与颜色一样,所以它的每个分量都处于[0,1]的范围之间。所以我们需要通过函数’f(x) = 2 * x - 1’将法线信息变换回它的原始形式。这个函数将0映射到-1,将1映射到 1。

现在我们需要将法线信息从切线空间中变换到世界坐标系中。我们用mat3类型的构造函数中的其中一个创建一个名为TNB的3x3矩阵,这个构造函数采用三个向量作为参数,这三个分量依次作为矩阵的第一行、第二行和第三行。如果你在疑惑为什么要以这样的顺序构造矩阵而不是其他的顺序,那么你只需要记住tangent对应于X轴,而bitangent对应于Y轴,至于法线向量则与Z轴相对应(参看上面的图片)。在标准的 3x3单位矩阵中,第一行对应其X轴,第二行对应其Y轴,第三行则对应其Z轴,我们只是依据这个顺序。将从纹理中提取的位于切线空间下的法线信息乘上TBN矩阵,并且将结果规范化之后再返回给调用者,这就得到了片元最终的法线信息。

本章中的示例还伴有三个JPEG文件:

  • ‘bricks.jpg’是颜色纹理;
  • ‘normal_map.jpg’是从’bricks.jpg’纹理中生成的法线纹理;
  • ‘normal_up.jpg’是一个也是一个发现纹理,但是这个纹理中所有发现都是朝上的。使用这个纹理作为法线纹理时,场景的效果就像没有使用法线纹理技术一样,我们可以通过绑定这个纹理来使得我们的法线纹理失效(尽管效率不是很高)。你可以通过按‘b’键在法线纹理和普通纹理之间的切换。

法线纹理被绑定在2号纹理单元中,并且此纹理单元专门用于存放法线纹理(0号纹理单元是颜色纹理,1号纹理单元存放阴影纹理)。

注意法线纹理的生成方式:

生成法线纹理的方法有很多,在这一节中我使用gimp 来生成法线纹理,它是一个免费开源的软件,有一个专门用于生成法线纹理的插件:normal map plugin。只要安装了这个插件,选择Filters->Map->Normalmap导入想要贴在模型上的纹理,之后会有多个与发现纹理相关的参数可供选择,达到满意的效果后点击‘OK’即可。这样在gimp软件视图中原来的普通纹理就会被新生成的法线纹理所替代,用新文件名将其保存下来即可在我们的着色器中使用了。

一步步学OpenGL(25) -《Skybox天空盒子》

教程 25

Skybox天空盒子

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial25/tutorial25.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

天空盒子是一种让场景看上去更广阔无垠的一种视觉技术,用无缝对接的封闭纹理将摄像机的视口360度无死角的包裹起来。封闭纹理通常是天空纹理和地形纹理(山脉、高楼大厦等)组合而成,当玩家在周围环境中探索的时候,视角中除了真实模型的其他空余部分被封闭纹理所完全填充充当背景。下面是一张‘半条命’游戏中天空盒子的示例图:

天空盒子的一种实现方法是渲染一个巨大的正六面体封闭盒子纹理,并将相机置于中心,当摄像机移动的时候封闭纹理也跟着移动,所以看上去永远走不到场景中的视平线边缘,就跟我们现实中慢慢行走却永远走不到地平线边缘类似的效果。另外天空和大地拼接在一起的纹理还和一个生活经验吻合:就是我们说天空在遥远的地平线处看上去接触到了大地,但是往前走地平线还在那个遥远的地方永远也过不去。正六面体天空盒子是一种典型的天空盒子纹理,它是用六张边缘无缝对接的正方形纹理拼接而成的,观察者在内部看上去是一个连续的背景,例如下面的纹理:

将上面纹理之间的白色边缘去掉并将六张纸片折叠拼成盒子就可以得到一个符合上面要求的天空盒子了。OpenGL中这种纹理叫做立方体贴图(Cubemap)。为了从立方体贴图中采样,我们要采用3d纹理坐标而不是我们之前用的2d纹理坐标了。纹理采样器将3d纹理坐标看做一个向量,找出该文素位于立方体的哪一个面上并从那个面上取出需要的文素。这个过程可以从下面的图片中看到(从上往下看盒子):

最合理的面的选择是基于纹理坐标中的那个最大分量的。在上面的例子中,我们可以看到Z分量是最大的(由于是从上往下看导致Y分量我们看不到,就先假设Y分量比Z分量小)。另外上面Z分量是正向的,因此采样器会从标记为‘PosZ’的面获取文素(其他的五个面还有’NegZ’,’PosX’,’NegX’,’PosY’和’NegY’)。

天空盒子技术除了用上面的立方体实现,还可以用球面来实现。主要区别是在球面上所有文素的方向向量长度都是相等的,因为都是半径,但在立方体中就不一样了。不过他们从面上取文素的机制是一样的。球面实现的天空空盒子叫做穹顶(skydome),这也是这篇教程中demo里采用的天空盒子,当然你应该两种方法都尝试看哪个效果更好。

源代码详解

(ogldev_cubemap_texture.h:28)
class CubemapTexture
{
public:

    CubemapTexture(const string& Directory,
        const string& PosXFilename,
        const string& NegXFilename,
        const string& PosYFilename,
        const string& NegYFilename,
        const string& PosZFilename,
        const string& NegZFilename);

    ~CubemapTexture();

    bool Load();

    void Bind(GLenum TextureUnit);

private:

    string m_fileNames[6];
    GLuint m_textureObj;
};

这个类封装了OpenGL中CubeMap纹理的实现并提供了两个简单的接口函数用于加载和使用该纹理。这个类的构造函数的参数包括纹理的文件目录和cubemap六个面的图片的文件名。简单起见,我们假设所有的图片文件都在同一个文件目录下,程序启动的时候我们要调用一次Load()函数来加载图像文件并创建OpenGL纹理对象。类有两个属性变量,一个是保存六张图片绝对路径的m_fileNames,另一个是OpenGL纹理对象句柄,这个句柄可以让我们访问cubemap所有的六个面。在运行时必须要使用合适的纹理单元调用Bind()函数来使shader着色器能够得到cubemap。

(cubemap_texture.cpp:60)
bool CubemapTexture::Load()
{
    glGenTextures(1, &m_textureObj);
    glBindTexture(GL_TEXTURE_CUBE_MAP, m_textureObj);

    Magick::Image* pImage = NULL;
    Magick::Blob blob;

    for (unsigned int i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(types) ; i++) {
        pImage = new Magick::Image(m_fileNames[i]);

        try { 
            pImage->write(&blob, "RGBA");
        }
        catch (Magick::Error& Error) {
            cout << "Error loading texture '" << m_fileNames[i] << "': " << Error.what() << endl;
            delete pImage;
            return false;
        }

        glTexImage2D(types[i], 0, GL_RGB, pImage->columns(), pImage->rows(), 0, GL_RGBA,
            GL_UNSIGNED_BYTE, blob.data());
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

        delete pImage;
    } 

    return true;
}

这个函数开始先创建一个纹理对象来加载cubemap纹理,这个对象绑定到了一个特殊的GL_TEXTURE_CUBE_MAP目标对象上。之后我们通过循环来遍历cubemap的六个面枚举(GL_TEXTURE_CUBE_MAP_POSITIVE_X, GL_TEXTURE_CUBE_MAP_NEGATIVE_X等等),六个枚举变量对应于m_fileNames的六个文件路径属性字符串。遍历过程中,通过ImageMagick框架依次加载六个图片文件,然后通过glTexImage2D()函数将资源数据传给OpenGL,注意每一次调用这个函数都要使用对应面的合适的GL枚举类型,因此枚举类型和文件路径数组m_fileNames必须是一一对应的。cubemap加载解析结束后,我们还需要做一些参数配置。除了GL_TEXTURE_WRAP_R其他的参数我们应该都很熟悉了,这个枚举参数指的就是纹理坐标的第三维,和其他维度的设置方法一样。

(cubemap_texture.cpp:95)
void CubemapTexture::Bind(GLenum TextureUnit)
{
    glActiveTexture(TextureUnit);
    glBindTexture(GL_TEXTURE_CUBE_MAP, m_textureObj);
}

在纹理用于绘制天空盒子之前这个函数必须要先调用,函数绑定的目标是GL_TEXTURE_CUBE_MAP,和我们在Load()函数中用的是同一个。

(skybox_technique.h:25)
class SkyboxTechnique : public Technique {
public:

    SkyboxTechnique();

    virtual bool Init();

    void SetWVP(const Matrix4f& WVP);
    void SetTextureUnit(unsigned int TextureUnit);

private:

    GLuint m_WVPLocation;
    GLuint m_textureLocation;
};

天空盒子是使用它自己的着色器函数来渲染的,函数调用只需要定义两个属性变量:一个将天空盒子纹理变换投影到屏幕的WVP矩阵和对应的纹理单元。看下面的内部结构:

(skybox.vs)
#version 330

layout (location = 0) in vec3 Position;

uniform mat4 gWVP;

out vec3 TexCoord0;

void main()
{
    vec4 WVP_Pos = gWVP * vec4(Position, 1.0);
    gl_Position = WVP_Pos.xyww;
    TexCoord0 = Position;
}

这个是天空盒子的顶点着色器代码,看上去很简单,但是一定要注意其中的技巧。第一个技巧是,虽然我们仍然是将输入的位置向量使用WVP矩阵进行变换,但是传给片段着色器的位置向量中的Z分量我们改成了W分量,这样会有什么结果呢?顶点着色器之后,光栅器将获得gl_Position向量,并进行透视分割以完成投影变换(将各分量除以W分量)。我们将Z分量设置成W分量的值可以保证透视分割后位置向量最终的Z分量值为1.0。Z分量为1意味着永远处于Z轴最远处,在深度测试中相对于其他物体模型天空盒子将永远处于劣势,因此天空盒子就总是作为其他物体的背景了,而其他物体会一直渲染在背景前面,这也是我们想要的效果。

第二个技巧是我们使用天空盒子自身坐标系中顶点的原始坐标来作为3D纹理坐标。为什么这样合理呢?因为对cubemap纹理采样时是从中心发射一个向量到立方体盒子或者球面上的,因此盒子表面上点的坐标恰好就是纹理坐标。顶点着色器将物体自身坐标系中的顶点坐标作为纹理坐标创给片段着色器(立方体是有8个顶点的,球体会有更多),然后光栅器会在顶点之间差值得到每个像素的位置,从而就可以利用每个像素的位置进行采样了。

(skybox.fs)
#version 330

in vec3 TexCoord0;

out vec4 FragColor;

uniform samplerCube gCubemapTexture;

void main()
{
    FragColor = texture(gCubemapTexture, TexCoord0);
}

片段着色器就极其简单了,唯一值得一提的是我们这里是要使用’samplerCube’而不是’sampler2D’以获取cubemap的纹理。

(skybox.h:27)
class SkyBox
{
public:
    SkyBox(const Camera* pCamera, const PersProjInfo& p);

    ~SkyBox();

    bool Init(const string& Directory,
        const string& PosXFilename,
        const string& NegXFilename,
        const string& PosYFilename,
        const string& NegYFilename,
        const string& PosZFilename,
        const string& NegZFilename);

    void Render();

private: 
    SkyboxTechnique* m_pSkyboxTechnique;
    const Camera* m_pCamera;
    CubemapTexture* m_pCubemapTex;
    Mesh* m_pMesh;
    PersProjInfo m_persProjInfo;
};

渲染天空盒子的过程中需要几个组件:一个着色器对象、一个cubemap纹理和一个立方体或者气体模型。为了简化使用,同一个盒子内的所有组件都封装在一个类中。在程序启动时就马上使用文件目录和cubemap纹理的文件名来进行天空盒子的初始化,然后会在运行时通过调用Render()函数来渲染天空盒子。单纯一个函数的调用就起到很多方面的作用,注意除了上面的组件,这个类还可以访问相机对象和透视变换的信息(FOV,Z以及屏幕尺寸),这也是为什么它能够合理的封装管线类。

void SkyBox::Render()
{
    m_pSkyboxTechnique->Enable();

    GLint OldCullFaceMode;
    glGetIntegerv(GL_CULL_FACE_MODE, &OldCullFaceMode);
    GLint OldDepthFuncMode;
    glGetIntegerv(GL_DEPTH_FUNC, &OldDepthFuncMode);

    glCullFace(GL_FRONT);
    glDepthFunc(GL_LEQUAL);

    Pipeline p; 
    p.Scale(20.0f, 20.0f, 20.0f);
    p.Rotate(0.0f, 0.0f, 0.0f);
    p.WorldPos(m_pCamera->GetPos().x, m_pCamera->GetPos().y, m_pCamera->GetPos().z);
    p.SetCamera(m_pCamera->GetPos(), m_pCamera->GetTarget(), m_pCamera->GetUp());
    p.SetPerspectiveProj(m_persProjInfo);
    m_pSkyboxTechnique->SetWVP(p.GetWVPTrans());
    m_pCubemapTex->Bind(GL_TEXTURE0);
    m_pMesh->Render(); 

    glCullFace(OldCullFaceMode); 
    glDepthFunc(OldDepthFuncMode);
}

这个函数用来负责天空盒子的渲染。开始先要启动天空盒子着色器,然后是一个新的OpenGL接口函数:glGetIntegerv(),这个函数可以返回OpenGL的状态。第一个参数就是状态的枚举,第二个参数是一个整型数组的引用地址,数组用来接收返回的状态(这个例子中只要一个整数就够了)。事实上我们是可以使用类似Get* 这样的函数来获取不同值类型的状态的,像:glGetIntegerv(), glGetBooleanv(), glGetInteger64v(), glGetFloatv() and glGetDoublev()。这里使用glGetIntegerv()的原因是我们要刻意改变glut_backend.cpp中一些通用的状态值,应用于这里所有的教程中,而且我们想在不影响其他部分的代码的前提下那样做。一个办法就是取出现在的状态,然后进行想要的改变,并且最后要将原本的状态值还原,这样其他系统就不需要知道这些状态的改变了。

第一个要改变的是表面剔除模式。通常,我们会剔除掉背向相机看不到的三角形图元,而对于天空盒子来说,相机是置于盒子内部的,所以我们想看到盒子的正面(内部)而不是背面(外部)。问题是,对于用到的一般的球体模型,外部的三角形是作为正面而内部的三角形是背面(这个取决于定点排列的顺序)。我们要么改变模型,要么就用相反的OpenGL剔除模式。事实是更倾向于选择后者的,因此同一个球体可以保持一般化用于其他地方,因此就要告诉OpenGL剔除去正面的三角形了。

第二个要改变的是深度测试函数模式。默认的,我们是告诉OpenGL,输入的片元如果比存储的片元Z值小就认为赢得深度测试而被渲染,但是对于天空盒子,Z值总是最远的边界,如果深度测试函数模式设置为‘小于’,天空盒子会被裁剪掉,为了让盒子成为场景的一部分我们要将深度测试函数模式改为‘小于等于’。

这个函数要做的另一件事情是计算WVP矩阵。注意对于天空盒子来说,世界坐标系的中心位于相机处,从而保证相机始终在天空盒子中心。之后cubemap的纹理贴图绑定到纹理单元0号上(天空盒子着色器初始化时设置的也是0号纹理单元),然后球面网格被渲染,最后原本的剔除模式和深度测试函数被还原。

有一个有趣的性能技巧是,将天空盒子的渲染放到所有其他模型最后。因为,我们知道天空盒子总是位于其他所有模型之后的,一些GPU会有优化机制使得在执行片段着色器之前就可以进行早期的深度测试,并丢弃那些测试失败的片元,这样对于提高天空盒子的渲染效率是很有用的,因为只需要对那些没被其他模型所覆盖的背景图元执行着色器即可。但是为了生效,我们必须获取封装了所有Z值的深度缓冲,那样当天空盒子要渲染时所有要用到的信息就都已经准备好了。

一步步学OpenGL(24) -《阴影贴图2》

教程 24

阴影贴图2

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial24/tutorial24.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

在之前的教程中,我们学习了阴影贴图技术背后的基本原理,并学习了如何在纹理中渲染深度信息并通过从深度缓冲中采样将其显示在屏幕上。这个教程我们将利用这些技术将阴影本身显示在屏幕上制作我们真正想要的阴影效果。

我们已经知道阴影贴图是一个有两个pass通道的技术(二次渲染),第一个pass通道我们是从光源的角度来渲染场景。现在我们看在第一次pass渲染通道中Z分量的位置向量经历了哪些过程:

  1. 通常情况下顶点着色器中的顶点位置是定义在本地坐标系的;
  2. 顶点着色器将顶点位置从本地空间转换到裁剪空间并继续传往渲染管线(关于裁剪空间可在教程12中复习回顾);
  3. 光栅化阶段光栅器会进行透视分割(位置向量除以它的W分量),将位置向量从裁剪空间转换到NDC空间。在NDC空间中所有最后显示到屏幕上的点的X,Y,Z分量都被转化到[-1,1]范围内,超出范围的部分都会被裁剪掉。
  4. 光栅器将位置向量的X和Y分量映射到帧缓冲空间中(例如:800x600, 1024x768等等),也就是将位置向量变换到屏幕空间中了。
  5. 光栅器在收到的屏幕空间坐标系下的三角形的三个顶点之间进行插值,从而为三角形中的每个点都创建一个自己的坐标,其中Z值(也在[-1,1]范围内)也是插值得到的因此每个像素都有它自己的深度信息。
  6. 由于第一轮渲染时颜色的写入被禁止,因此此时片元着色器无效,但深度测试依然会进行。为了让当前像素的深度值与缓存中的像素的深度值进行比较,我们可以使用像素的屏幕坐标来从深度缓存中获取像素的深度值。如果当前像素的深度值比缓存中的小,则更新缓存(如果颜色写入开启,则颜色缓存中的值也会被更新)。

通过上面的过程我们知道了从光源的角度深度值是如何计算和存储的,接下来在第二次渲染的过程中,我们要从相机的角度来进行场景渲染所以我们会得到不同的深度信息。不过两次得到的深度信息我们都是需要的:一个使得三角形图元能够按顺序能够正确绘制在屏幕上,另一个用来检查哪些片元位于阴影中哪些不在。实现阴影贴图的技巧就是在3D渲染管线中维护两个位置向量和两个WVP矩阵。其中一个WVP矩阵从光源的角度算出,而另一个从相机角度计算得到。顶点着色器还是正常接收一个局部坐标系下定义的位置向量,但它会输出两个向量:

  • gl_Position内置变量,它是经过相机的WVP矩阵变换之后的结果;
  • 另一个是一个普通向量,它是经过光源WVP变换矩阵变换得到的。

gl_Position向量会经历上述的一系列处理过程(…变换到NDC空间中…等等),然后会用于常规的光栅化阶段。第二个普通向量只会被光栅化阶段的光栅器在三角形面上进行插值处理并为每一个片段着色器提供它自己的值。所以现在当我们从从光源的角度看时,对于每一个物理像素我们都有原三角形中同一个点的裁剪空间坐标与之对应。很可能某个物理像素从两个角度看是不同的,但它在三角形中的位置实际是一样的。最后要做的就是用那个裁剪空间坐标来从shadow map中取深度值了。之后我们就可以将阴影纹理中的深度值与位于裁剪坐标系中的深度值进行比较,如果存储的剪裁坐标的深度值比较小,那就意味着像素位于阴影中(因为另一个像素和该像素有相同的裁剪空间坐标但另一个的深度值更小)。

那如何从片段着色器中获取经过光源WVP矩阵在裁剪空间坐标系下变换后的深度信息呢?

  • 由于片段着色器将接收到的裁剪空间下的坐标看做一个标准的顶点属性,光栅化程序不会对其进行透视分割(只有传到gl_position变量中的顶点才会自动执行透视分割)。但在shader中我们可以很方便的来手动实现这一功能,我们将这个向量除以其W分量就将其变换到NDC空间中了;

  • 我们知道在NDC空间中,顶点的X,Y分量都位于[-1,1]范围内,在上面的第四步中光栅化程序将NDC空间中的坐标映射到屏幕空间中,并且用它们来存放深度信息。现在我们想要从中提取出深度信息,为此我们需要一个位于[0,1]范围内的纹理坐标。如果我们将范围在[-1,1]之间的NDC空间坐标线性的映射到[0,1]的范围中,那么就会得到一个纹理坐标,这个纹理坐标会映射到阴影纹理的同一位置。例如:在NDC空间中一个顶点的X坐标为0,现在纹理的宽度为800,那么位于NDC空间中的0需要被映射到纹理坐标空间中的0.5(因为0位于[-1,1]的中点)。纹理空间中的0.5被映射到纹理上面的400,而这同样是光栅器执行屏幕空间转换时需要对其进行计算的地方。
  • 将X和Y坐标从NDC空间变换到纹理空间的方法如下:

    u = 0.5 * X + 0.5

    v = 0.5 * Y + 0.5

源代码详解

(lighting_technique.h:80)
class LightingTechnique : public Technique {
    public:
    ...	
        void SetLightWVP(const Matrix4f& LightWVP);
        void SetShadowMapTextureUnit(unsigned int TextureUnit);
    ...
    private:
        GLuint m_LightWVPLocation;
        GLuint m_shadowMapLocation;
...

这里LightingTechnique类需要两个新的属性变量。一个从光源视角计算得到的WVP矩阵,一个是用来存放阴影贴图的纹理单元。我们会继续使用纹理单元0来存放映射匹配到模型上的普通纹理,并且会用于纹理单元1以存放阴影纹理。

(lighting.vs)
#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;
uniform mat4 gLightWVP;
uniform mat4 gWorld;

out vec4 LightSpacePos;
out vec2 TexCoord0;
out vec3 Normal0;
out vec3 WorldPos0;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    LightSpacePos = gLightWVP * vec4(Position, 1.0);
    TexCoord0 = TexCoord;
    Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
    WorldPos0 = (gWorld * vec4(Position, 1.0)).xyz;
}

这里,LightingTechnique类的顶点着色器已经更新,添加了部分新的内容。我们又加入了一个WVP矩阵的一致变量,和一个作为输出的的4维向量,这个向量包含了经过光源WVP矩阵变换后位置的裁剪空间坐标。可以看到,顶点着色器中第一次渲染通道中的gWVP变量和这里的gLightWVP变量有着相同的矩阵,那里的gl_Position变量会得到和这里的LightSpacePos变量相同的结果值。 但由于LightSpacePos只是个标准的向量,它不会像gl_Position那样自动被透视分割,我们要手动在下面的片段着色器中进行该操作。

(lighting.fs:58)
float CalcShadowFactor(vec4 LightSpacePos)
{
    vec3 ProjCoords = LightSpacePos.xyz / LightSpacePos.w;
    vec2 UVCoords;
    UVCoords.x = 0.5 * ProjCoords.x + 0.5;
    UVCoords.y = 0.5 * ProjCoords.y + 0.5;
    float z = 0.5 * ProjCoords.z + 0.5;
    float Depth = texture(gShadowMap, UVCoords).x;
    if (Depth < (z + 0.00001))
        return 0.5;
    else
        return 1.0;
}

这个函数是在片段着色器中用来计算像素的阴影参数的。阴影参数是光源方程中的一个新参数。 我们这只是将光源方程计算的颜色结果值和这个阴影参数相乘,使定义在阴影中的像素亮度有所衰减。这个函数的参数是来自顶点着色器经过插值后的LightSpacePos向量。首先第一步是要进行透视变换(将XYZ分量除以W分量),从而将向量转换到NDC空间。然后准备一个2D坐标向量作为纹理坐标,之后按照上面背景介绍中的公式将透视分割后的LightSpacePos向量从NDC空间转换到纹理坐标空间来初始化这个纹理坐标向量。纹理坐标是用来从阴影贴图中获取深度数据的,这个深度指的是场景中所有投影到这个像素上的点中最近的那个点的深度。我们将上面阴影贴图的深度值和当前像素的深度值进行比较,如果阴影贴图的深度值小,也就是阴影离相机近,那么就返回0.5作为阴影参数,反之就返回1.0表示没有阴影。另外,来自NDC空间的Z分量也要经历从(-1,1)范围到(0,1)范围的转换,因为我们在比较的时候必须得要在相同的空间内。注意这里我们实际在当前的像素深度上加了一个很小的校正值来避免处理浮点数造成的精度错误。

(lighting.fs:72)
vec4 CalcLightInternal(BaseLight Light, vec3 LightDirection, vec3 Normal, float ShadowFactor)
{
    ...
    return (AmbientColor + ShadowFactor * (DiffuseColor + SpecularColor));
}

光照计算的核心函数没有什么大的变化,调用者必须将阴影参数传进来并调整漫射光和镜面反射光的颜色值。环境光就不受阴影的影响了,因为根据定义环境光是无处不在的没有阴影这一说。

(lighting.fs:97)
vec4 CalcDirectionalLight(vec3 Normal)
{
    return CalcLightInternal(gDirectionalLight.Base, gDirectionalLight.Direction, Normal, 1.0);
}

我们的阴影贴图的实现目前只适应于聚光灯光源,为了计算光源的WVP矩阵我们既需要光源的位置,还要光源的方向,位置和方向是点光源或平行光所不都具有的。以后我们会将确实的部分再加上,现在对于平行光就简单设置阴影参数为1了。

(lighting.fs:102)
vec4 CalcPointLight(struct PointLight l, vec3 Normal, vec4 LightSpacePos)
{
    vec3 LightDirection = WorldPos0 - l.Position;
    float Distance = length(LightDirection);
    LightDirection = normalize(LightDirection);
    float ShadowFactor = CalcShadowFactor(LightSpacePos);

    vec4 Color = CalcLightInternal(l.Base, LightDirection, Normal, ShadowFactor);
    float Attenuation = l.Atten.Constant +
        l.Atten.Linear * Distance +
        l.Atten.Exp * Distance * Distance;

    return Color / Attenuation;
}

聚光灯光源其实是在点光源的基础上计算出来的,这里这个函数接受了一个光源空间位置的一个新的参数LightSpacePos,并计算阴影参数,然后将其传给 CalcLightInternal()函数。

(lighting.fs:117)
vec4 CalcSpotLight(struct SpotLight l, vec3 Normal, vec4 LightSpacePos)
{
    vec3 LightToPixel = normalize(WorldPos0 - l.Base.Position);
    float SpotFactor = dot(LightToPixel, l.Direction);

    if (SpotFactor > l.Cutoff) {
        vec4 Color = CalcPointLight(l.Base, Normal, LightSpacePos);
        return Color * (1.0 - (1.0 - SpotFactor) * 1.0/(1.0 - l.Cutoff));
    }
    else {
        return vec4(0,0,0,0);
    }
}

这里聚光灯光源计算函数只是将光源空间位置LightSpacePos传给点光源计算函数。

(lighting.fs:131)
void main()
{
    vec3 Normal = normalize(Normal0);
    vec4 TotalLight = CalcDirectionalLight(Normal);

    for (int i = 0 ; i < gNumPointLights ; i++) {
        TotalLight += CalcPointLight(gPointLights[i], Normal, LightSpacePos);
    }

    for (int i = 0 ; i < gNumSpotLights ; i++) {
        TotalLight += CalcSpotLight(gSpotLights[i], Normal, LightSpacePos);
    }

    vec4 SampledColor = texture2D(gSampler, TexCoord0.xy);
    FragColor = SampledColor * TotalLight;
}

最后就是片段着色器的主函数了。对于聚光灯光源和点光源我们都传入了相同的光源空间位置向量LightSpacePos,虽然只有聚光灯光源会支持(因为传入的光源WVP矩阵都是以聚光灯光源计算的),这个限制之后我们会修正。lightingTechnique类中代码的改变已经介绍了,下面看应用代码。

(tutorial24.cpp:86)
m_pLightingEffect = new LightingTechnique();

if (!m_pLightingEffect->Init()) {
    printf("Error initializing the lighting technique\n");
    return false;
}

m_pLightingEffect->Enable();
m_pLightingEffect->SetSpotLights(1, &m_spotLight);
m_pLightingEffect->SetTextureUnit(0);
m_pLightingEffect->SetShadowMapTextureUnit(1);

这段创建lightingTechnique类对象的代码属于Init()函数的一部分,所以它只会在程序启动的时候执行一次,这里设置了一致变量来防止其随着帧的改变而变动。我们使用0号纹理单元作为标准纹理单元(模型原有的纹理),另外阴影纹理存在1号纹理单元中。注意着色器程序必须要在一直变量创建之前开启,这些一致变量只要在程序没有重新连接前是一直保持不变的,这样会很方便因为可以灵活地在着色器程序之间切换,只需要关心那些动态的一致变量就好了,那些静态的一致变量在最开始初始化一次之后就不会再变了。

(tutorial24.cpp:129)
virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_scale += 0.05f;

    ShadowMapPass();
    RenderPass();

    glutSwapBuffers();
}

在住渲染函数内没有发生变化,开始先设置相机和旋转模型mesh的scale因子这些全局变量,然后分别执行阴影通道和渲染通道。

(tutorial24.cpp:141)
virtual void ShadowMapPass()
{
    m_shadowMapFBO.BindForWriting();

    glClear(GL_DEPTH_BUFFER_BIT);

    m_pShadowMapEffect->Enable();

    Pipeline p;
    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 3.0f);
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapEffect->SetWVP(p.GetWVPTrans());
    m_pMesh->Render();

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

这个基本上和之前教程的阴影通道相同,唯一的变化是每次渲染之前都要开启阴影贴图,因为我们要在这个阴影计算和光照计算两个着色器程序之间之间进行切换。需要注意的一点是:虽然我们场景中既有一个mesh网格模型又有作为地面的矩形模型,但只有mesh网格模型会渲染到阴影纹理中,因为地面是不会投出阴影的,所以这里根据模型的类型也可以对模型渲染进行一些优化。

(tutorial24.cpp:168)
virtual void RenderPass()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pLightingEffect->Enable();

    m_pLightingEffect->SetEyeWorldPos(m_pGameCamera->GetPos()); 
    m_shadowMapFBO.BindForReading(GL_TEXTURE1);

    Pipeline p;
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);

    p.Scale(10.0f, 10.0f, 10.0f);
    p.WorldPos(0.0f, 0.0f, 1.0f);
    p.Rotate(90.0f, 0.0f, 0.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    m_pLightingEffect->SetWVP(p.GetWVPTrans());
    m_pLightingEffect->SetWorldMatrix(p.GetWorldTrans());
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    m_pLightingEffect->SetLightWVP(p.GetWVPTrans());
    m_pGroundTex->Bind(GL_TEXTURE0);
    m_pQuad->Render();

    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 3.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    m_pLightingEffect->SetWVP(p.GetWVPTrans());
    m_pLightingEffect->SetWorldMatrix(p.GetWorldTrans());
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    m_pLightingEffect->SetLightWVP(p.GetWVPTrans());
    m_pMesh->Render();
}

一开始渲染通道和之前的一样,我们先清空深度缓冲和颜色缓冲,使用光照渲染着色器代替阴影贴图渲染着色器并绑定阴影贴图的FBO到纹理单元1号以供读取。然后我们渲染矩形作为地面,从而阴影可以投在地面上。地面稍微放大了一些,并绕X轴旋转90度调整好位置(地面原本是朝向相机的)。要注意WVP矩阵是如何随着相机的位置的不同而变化的,而对于光源WVP我们直接将相机置于光源位置就是固定的了。另外由于矩形地面模型本来是没有纹理的我们就手动为其绑定一个纹理贴图,mesh网格模型的渲染也是这样。

一步步学OpenGL(23) -《阴影贴图1》

教程 23

阴影贴图1

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial23/tutorial23.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

阴影和光是紧密联系的,正如你需要光才能投射出阴影。有许多的技术可以生成阴影,在接下来的两个章节中我们将学习一种基础而简单的技术-阴影贴图。

当涉及到光栅化和阴影的问题时,你可能会问这个像素是否位于阴影中?或者说,从光源到像素的路径是否通过其他物体?如果是,这个像素可能位于阴影中(假定其他的物体不透明),否则,则像素不位于阴影中。从某种程度上讲,这个问题类似于我们在之前的教程中问的问题:如何确定当两个物体重叠时,我们看到的是比较近的那个?如果我们把相机放在光源的位置,那么这两个问题就是一会儿事儿了。我们希望在深度测试中落后的像素是因为像素处于阴影中。只有在在深度测试中获胜的像素才会受到光的照射。这些像素都是直接和光源接触的,其间没有任何东西会遮蔽它们。这就是在阴影贴图背后的原理。

看似深度测试可以帮助我们检测一个像素是否位于阴影中,但是还有一个问题:相机和光源并不总是位于同一个地方。深度测试通常用于解决从相机角度看物体是否可见的问题。那么当光源处于远处的时候,我们如何利用深度测试来进行阴影测试?解决方案是渲染场景两次。首先从光源的角度来看,此时渲染通道的结果并没有存储到颜色缓冲区中,相反,离光源最近的深度值被渲染到应用程序创建的深度缓冲区中(而不是由GLUT自动生成的);其次,从摄像机的角度来看场景,我们创建的深度缓冲区被绑定到片元着色器以便读取。对于每一个像素,我们从这个深度缓冲区中取出相应的深度值,同时我们也计算这个像素到光源的距离。有时候这两个深度值是相等的。说明这个像素与光源最近,因此它的深度值才会被写进深度缓冲区,此时,这个像素就被认为处于光照中会和正常情况一样去计算它的颜色。如果这两个深度值不同,意味着从光源看这个像素时有其他像素遮挡了它,这种情况下我们在颜色计算中要增加阴影因子来模仿阴影效果。看下面这幅图:

以上场景由两个对象组成——物体表面和立方体。光源是位于左上角并且指向立方体。在第一次渲染过程中,我们从光源的角度呈现深度缓冲区。看图中A,B,C这3个点。当B被渲染时,它的深度值进入深度缓冲区,因为在B和光源之间没有任何东西,我们默认它是那条线上离光源最近的点。然而当A和C被渲染的时候,它们在深度缓冲区的同一个点上“竞争”。两个点都在同一条来自光源的直线上,所以在透视投影后,光栅器发现这两个点需要去往屏幕上的同一个像素。这就是深度测试,最后C点“赢”了,则C点的深度值被写入了深度缓存中。

在第二个渲染过程中,我们从摄像机的角度渲染表面和立方体。我们在着色器中除了为每个像素做一些计算,我们还计算从光源到像素之间的距离,并和在深度缓冲区中对应的深度值进行比较。当我们光栅化B点时,这两个值应该是差不多相等的(可能由于插值的不同和浮点类型的精度问题会有一些差距),因此我们认为B不在阴影中而和往常一样进行计算。当光栅化A点的时候,我们发现储存的深度值明显比A到光源的距离要小。所以我们认为A在阴影中,并且在A点上应用一些阴影参数,使它比以往暗一些。

简言之,这就是阴影映射算法(我们在第一次渲染通道中渲染的深度缓冲称为“阴影贴图”),我们将分两个阶段学习它。在第一个阶段(本节)我们将学习如何将深度信息渲染到阴影图中,渲染一个由应用程序创建的纹理,被称为 ‘纹理渲染 ;我们将使用一个简单的纹理映射技术在屏幕上显示阴影贴图,这是一个很好的调试过程,为了得到完整的阴影效果,正确的绘制阴影贴图是至关重要的。在下一节我们将看见如何使用阴影图来计算顶点“是否处于阴影中”。

这一节我们使用的模型是一个简单的可以用来显示阴影贴图的四边形网格。这个四边形是由两个三角形组成的,并设置纹理坐标使它们覆盖整个纹理。当四边形被渲染的时候,纹理坐标被光栅器插值,于是就可以采样整个纹理并将其显示在屏幕上。

源代码详解

(shadow_map_fbo.h:50)

class ShadowMapFBO
{
    public:
        ShadowMapFBO();

        ~ShadowMapFBO();

        bool Init(unsigned int WindowWidth, unsigned int WindowHeight);

        void BindForWriting();

        void BindForReading(GLenum TextureUnit);

    private:
        GLuint m_fbo;
        GLuint m_shadowMap;
};

在OpenGL中3d管线输出的结果称为‘帧缓冲对象‘(简称FBO)。FBO可以挂载颜色缓冲(在屏幕上显示)、深度缓冲区和一些有其他用处的缓冲区。当glutInitDisplayMode()被调用的时候,它使用一些特定的参数来创建默认的帧缓存,这个帧缓存被窗口系统所管理,不会被OpenGL删除。除了默认的帧缓存,应用程序可以创建自己的FBOs。在应用程序的控制下,这些对象可以被操作以用于不同的技术当中。ShadowMapFBO类为FBO提供一个容易操作的接口,会被FBO用来实现阴影贴图技术。ShadowMapFBO类内部有两个OpenGL句柄,其中‘m_fbo’句柄代表真正的FBO,FBO封装了帧缓存所有的状态,一旦这个对象被创建并设置合适的参数,我们就可以简单的通过绑定不同的对象来改变帧缓存。注意只有默认的帧缓存才可以在屏幕上显示。应用程序创建的帧缓存只能用于”离屏渲染“,这个可以说是一个中间的渲染过程(比如我们的阴影贴图缓冲区),稍后可以用于屏幕上的“真实”渲染通道。

就其本身而言,帧缓存只是一个占位符,为了使它变得可用,我们需要把纹理依附于一个或者更多的可用的挂载点,纹理含有帧缓存实际的内存空间。OpenGL定义了下面的一些附着点:

  • COLOR_ATTACHMENTi:附着到这里的纹理将接收来自片元着色器的颜色。‘i’ 后缀意味着可以有多个纹理同时被附着为颜色附着点。在片元着色器中有一个机制可以确保同时渲染多个颜色到缓冲区。
  • DEPTH_ATTACHMENT:附着在上面的纹理将收到深度测试的结果。
  • STENCIL_ATTACHMENT:附着在上面的纹理将充当模板缓冲区。模板缓冲区限制了光栅化的区域,可被用于不同的技术。
  • DEPTH_STENCIL_ATTACHMENT:这仅是一个深度和模板缓冲区的结合,因为它俩经常被一起使用。

对于阴影映射技术,我们只需要一个深度缓冲。成员属性“m_shadowmap“是附加到DEPTH_ATTACHMENT附着点的纹理句柄。ShadowMapFBO也提供了一些方法,主要用在渲染功能上。在开始第二次渲染的时候,我们要在渲染到阴影图和BindForReading()之前调用BindForWriting()。

(shadow_map_fbo.cpp:43)

glGenFramebuffers(1, &m_fbo);

这里我们创建FBO。和纹理与缓冲区这些对象的创建方式一样,我们指定一个GLuints数组的地址和它的大小,这个数组被句柄填充。

(shadow_map_fbo.cpp:46)

glGenTextures(1, &m_shadowMap);
glBindTexture(GL_TEXTURE_2D, m_shadowMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, WindowWidth, WindowHeight, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

接下来我们创建纹理来作为阴影图。在一般情况下,这是一个标准的有特定配置的2D纹理,使其用于达到以下目的:

  1. 纹理的内部格式是 GL_DEPTH_COMPONENT 。和之前不同,之前我们通常将纹理的内部格式设置为与颜色有关的类型如(GL_RGB),这里我们将其设置为 GL_DEPTH_COMPONENT,意味着纹理中的每个纹素都存放着一个单精度浮点数用于存放已经标准化后的深度值。
  2. glTexImage2D的最后一个参数是空,意味着我们不提供任何用于初始化buffer的数据,因为我们想让buffer包含每一帧的深度值并且每一帧的深度值都可能会变化。无论我们何时开始一个新的帧,我们都要用glClear()清除buffer。这些是我们在初始化过程中要做的。
  3. 我们告诉OpenGL如果纹理坐标越界,需要将其截断到[0,1]之间。当以相机为视口的投影窗口超过以光源为视口的投影窗口时会发生纹理坐标越界。为了避免不好的现象比如由于wraparound的原因阴影在别的地方重复出现,我们要截断纹理坐标。 (shadow_map_fbo.cpp:54)

glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);

我们已经生成FBO纹理对象,并为阴影贴图配置了纹理对象,现在我们需要把纹理对象附到FBO。我们要做的第一件事就是绑定FBO,之后所有对FBO的操作都会对它产生影响。这个函数的参数是FBO句柄和所需的target。target可以是GL_FRAMEBUFFER,GL_DRAW_FRAMEBUFFER或者GL_READ_FRAMEBUFFER。GL_READ_FRAMEBUFFE在我们想调用glReadPixels(本教程中不会使用)从FBO中读取内容时会用到;当我们想要把场景渲染进入FBO时需要使用GL_DRAW_FRAMEBUFFE;当我们使用GL_FRAMEBUFFER时,FBO的读写状态都会被更新,建议这样初始化FBO;当我们真正开始渲染的时候我们会使用GL_DRAW_FRAMEBUFFER。

(shadow_map_fbo.cpp:55) glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, m_shadowMap, 0);

这里我们把shadow map纹理附着到FBO的深度附着点上。这个函数最后一个参数指明要用的Mipmap层级。Mipmap层是纹理贴图的一个特性,以不同分辨率展现一个纹理。0代表最大的分辨率,随着层级的增加,纹理的分辨率会越来越小。将Mipmap纹理和三线性滤波结合起来能产生更好的结果。这里我们只有一个mipmap层,所以我们使用0。我们让shadow map句柄作为第四个参数。如果这里我们使用0,那么当前的纹理(在上面的例子是深度)将从指定的附着点上脱落。

(shadow_map_fbo.cpp:58)
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);

因为我们没打算渲染到color buffer(只输出深度),我们通过上面的函数来禁止向颜色缓存中写入。默认情况下,颜色缓存会被绑定在GL_COLOR_ATTACHMENT0上,但是我们的FBO中甚至不会包含一个纹理缓冲区,所以,最好明确的告诉OpenGL我们的目的。这个函数可用的参数是GL_NONE和GL_COLOR_ATTACHMENT0到 GL_COLOR_ATTACHMENTm,‘m’是(GL_MAX_COLOR_ATTACHMENTS–1)。这些参数只对FBOs有效。如果用了默认的framebuffer,那么有效的参数是GL_NONE, GL_FRONT_LEFT,GL_FRONT_RIGHT,GL_BACK_LEFT和GL_BACK_RIGHT,这使你可以直接将场景渲染到front buffer或者back buffer(每一个都有左left和right buffer)。我们也将从缓存中的读取操作设置为GL_NONE(注意,我们不打算调用glReadPixel APIs中的任何一个函数)。这主要是为了避免因GPU只支持 opengl3.x而不支持4.x而出现问题。

(shadow_map_fbo.cpp:61)

GLenum Status = glCheckFramebufferStatus(GL_FRAMEBUFFER);

if (Status != GL_FRAMEBUFFER_COMPLETE) {
    printf("FB error, status: 0x%x\n", Status);
    return false;
}

当我们完成FBO的配置后,一定要确认其状态是否为OpenGL定义的“complete”,确保没有错误出现并且framebuffer现在是可用的了。上面就是检验这个的代码。 (shadow_map_fbo.cpp:72)

void ShadowMapFBO::BindForWriting()
{
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, m_fbo);
}

在渲染过程中我们需要将渲染目标在shadow map和默认的framebuffer之间进行切换。在第二个渲染过程中,我们要绑定shadow map作为输入。这个函数和下一个函数将这个工作封装起来便于调用。上面的函数仅绑定FBO用于写入数据,在第一次渲染之前我们将调用它。

(shadow_map_fbo.cpp:78)

void ShadowMapFBO::BindForReading(GLenum TextureUnit)
{
    glActiveTexture(TextureUnit);
    glBindTexture(GL_TEXTURE_2D, m_shadowMap);
}

这个函数在第二次渲染之前被调用以绑定shadow map用于读取数据。注意我们是绑定纹理对象而不是FBO本身。这个函数的参数是纹理单元,并把shadow map绑定到这个纹理单元上。这个纹理单元的索引一定要和着色器同步(因为着色器有一个sampler2D一致变量用来访问这个纹理)。注意glActiveTexture的参数是纹理索引的枚举值(比如GL_TEXTURE0,GL_TEXTURE1等),着色器中的一致变量只需要索引值本身(如0,1等),这可能会导致很多bug出现。

(shadow_map.vs)

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;

out vec2 TexCoordOut;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoordOut = TexCoord;
}

我们将在两次的渲染中都使用同一着色器程序。顶点着色器在两次渲染过程中都用得到,而片元着色器将只在第二次渲染过程中被使用。因为我们在第一次渲染过程中禁止把数据写入颜色缓存,所以就没用到片元着色器。上面的顶点着色器是十分简单的,它仅仅是通过WVP矩阵将位置坐标变换到裁剪坐标系中,并将纹理坐标传递到片元着色器中。在第一次的渲染过程中,纹理坐标是多余的(因为没有片元着色器)。然而,这没有实际的影响。可以看出,从着色器角度来看,无论这是一个渲染深度的过程还是一个真正的渲染过程都没有什么不同,而真正不同的地方是应用程序在第一次渲染过程传递的是以光源为视口的WVP矩阵,而在第二次渲染过程传递的是以相机为视口的WVP矩阵。在第一次的渲染过程Z buffer将用最靠近光源位置的Z值所填充,在第二次渲染过程中,Z buffer将被最靠近相机位置的Z值所填充。在第二次渲染过程中我们需要使用片元着色器中的纹理坐标,因为我们将从shadow map(此时它是着色器的输入)中进行采样。

(shadow_map.fs)

#version 330

in vec2 TexCoordOut;
uniform sampler2D gShadowMap;

out vec4 FragColor;

void main()
{
    float Depth = texture(gShadowMap, TexCoordOut).x;
    Depth = 1.0 - (1.0 - Depth) * 25.0;
    FragColor = vec4(Depth);
}

这是在渲染过程中用来显示shadow map的片元着色器。2D纹理坐标用来从shadow map中进行采样。Shadow map纹理是以GL_DEPTH_COMPONENT类型为内部格式而创建的,意味着纹理中每一个纹素都是一个单精度的浮点型数据而不是一种颜色。这就是为什么在采样的过程中要使用’.x’。当我们显示深度缓存中的内容时,我们可能遇到的一个情况是渲染的结果不够清楚。所以,在我们从shadow map中采样获得深度值后,为使效果明显,我们放大当前点的距离到远边缘(此处Z为1),然后再用1减去这个放大后值。我们将这个值作为片元的每个颜色通道的值,意味着我们将得到一些灰度的变化(远裁剪面处是白色,近裁剪面处是黑色)。

现在我们如何结合上面的这些代码片段来创建应用程序。

(tutorial23.cpp:106)
virtual void RenderSceneCB()
{
    m_pGameCamera->OnRender();
    m_scale += 0.05f;

    ShadowMapPass();
    RenderPass();

    glutSwapBuffers();
}

主渲染程序随着大部分的功能移到其他函数中变得更加简单了。我们先处理全局的东西,比如更新相机的位置和用来旋转对象的类成员。然后我们调用一个ShadowMapPass()函数将深度信息渲染到shadow map纹理中,接着用RenderPass()函数来显示这个纹理。最后调用glutSwapBuffer()来将最终结果显示到屏幕上。 (tutorial23.cpp:117)

virtual void ShadowMapPass()
{
    m_shadowMapFBO.BindForWriting();

    glClear(GL_DEPTH_BUFFER_BIT);

    Pipeline p;
    p.Scale(0.1f, 0.1f, 0.1f);
    p.Rotate(0.0f, m_scale, 0.0f);
    p.WorldPos(0.0f, 0.0f, 5.0f);
    p.SetCamera(m_spotLight.Position, m_spotLight.Direction, Vector3f(0.0f, 1.0f, 0.0f));
    p.SetPerspectiveProj(20.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());

    m_pMesh->Render();

    glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

在渲染Shadow map之前我们先绑定FBO。从现在起,所有的深度值将被渲染到shadow map中,同时舍弃颜色的写入过程。我们只在渲染开始之前清除深度缓冲区,之后我们为了渲染mesh(例子为一个坦克)初始化了一个pipeline类对象。这里值得注意的一点是相机相关设置是基于聚光灯的位置和方向的。我们先渲染mesh,然后通过绑定FBO为0来切换回默认的framebuffer。

(tutorial23.cpp:135)

virtual void RenderPass()
{
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    m_pShadowMapTech->SetTextureUnit(0);
    m_shadowMapFBO.BindForReading(GL_TEXTURE0);

    Pipeline p;
    p.Scale(5.0f, 5.0f, 5.0f);
    p.WorldPos(0.0f, 0.0f, 10.0f);
    p.SetCamera(m_pGameCamera->GetPos(), m_pGameCamera->GetTarget(), m_pGameCamera->GetUp());
    p.SetPerspectiveProj(30.0f, WINDOW_WIDTH, WINDOW_HEIGHT, 1.0f, 50.0f);
    m_pShadowMapTech->SetWVP(p.GetWVPTrans());
    m_pQuad->Render();
}

在第二个渲染过程开始前,我们先清除颜色和深度缓存,这些缓冲区属于默认的帧缓存。我们告诉着色器使用纹理单元0,并绑定阴影贴图用来读取其中的数据。从这里开始处理就都和以前一样了。我们放大四边形,把它直接放在相机的前面并渲染它。在光栅化期间进行采样阴影贴图并将其显示到模型上。

注意:在这个教程的代码中,当网格文件没有指定一个纹理时,我们不再自动加载一个白色的纹理,因为现在可以绑定阴影贴图来代替。如果网格不包含纹理我们就什么都不绑定,而是调用代码让其绑定自己的纹理。

2016人生大跃进,今年我是自己的赢家

于我,2016实在是太不平凡,首先感谢过去一年的我,完成自己关键阶段的自我升华。回头想想,收获真的太大,本科前三年过得非常充实,大四一年沉住了气,没有去放松,而是去升华自己,前三年是脱茧一样的痛苦去学习,最后一年在加速学习的同时开始学会思考自己所学的东西。前三年的努力,使自己最后算是圆了名校梦,拿到哈工大的准保研和港大的offer;在游戏公司实习期间,利用一星期自学入门了Unity踏入3d游戏的大门,根据兴趣选定了图形学方向并开始深入学习和研究,在已有的经验基础上开始反思总结iOS平台技术成功进阶,来港学习后眼界大开,学习计划更加明确,开始真正为找工作做准备,第一次参加了网易游戏这种高级别的大公司面试收货很大,发现了自己的不足为以后毕业求职打下了基础,另外2016的开始我开始在CSDN安家,养成了总结整理归纳所学所思的习惯,半年多拿到博客专家的称号,对自己鼓舞很大,感谢有这样的平台能和志同道合的人交流学习共同进步,在这里我学会了:潜心学习,努力分享!

***

2016年1月

经过之前的较长时间的奋斗准备,终于拿到港大的面试通知,一月中旬面试后成功拿到计算机专业录取,决定来港继续学习;

2016年3月

正式搬家CSDN,开始总结学习,练习归纳写文章,从最初的幼稚潦草,慢慢系统化规范化,最后还是积累了不少自己的思路想法,能够及时回头复习回顾,在平台上互相学习,共同进步,从最初的只会拿来主义到能够互相分享互利。

2016年3月-8月

利用大四下半学期,在游戏公司学习工作,短时间自学大量3d游戏开发知识和技术,同时结合毕业设计,研究和学习游戏领域算法以及游戏领域热点技术,总结自己大学期间所学所做,在理论和技术上有所升华;建立了自己的独立博客;

2016年9月

来港学习,认识很多新朋友,满足自己的好奇心体验了很多新鲜感的东西,虽然某些地方不太适应,但整体上学习生活还是很丰富的,学习上在更高的层次去学习思考领悟,收获非常大。

2016年12月

拿到网易游戏研发工程师的面试通知,刚面试回来,收获很大,虽说让等结果,但结果倒是不重要了,没想那么容易就录用,本来也是本着学习的态度提前去体验一下,为以后毕业找工作积累经验,目的已经达到,这也是年底最大的收获。

新鲜的网易游戏研发工程师面经:记2016网易游戏冬季校招面试

最后,我的2016很精彩,我对2017很期待,接下来,知道自己的不足就要继续努力学习,自我塑造,自我完善。还有在CSDN平台继续:潜心学习,努力分享!


附:自己的一首小诗

由于各种原因,加上重要学习任务在身,今年过年不能回家了,还是很想家的,希望家人都好,希望自己和身边的人身体健康天天开心,也祝所有奋斗在自己梦想线上的程序员们学有所成天天进步,CSDN上的朋友们共同学习进步。

《江城子》

港海舟悬冬未寒。对樽前。忆家欢。再把荷花,孤恨倚阑干。椰林断烟薄扶路,饮卮泪,望平山。

何时念破是非缘。云水间,静无言。奈何睡去,塞北又依然。弃枕辗转空懊恼,梦水湾,是江南。

一步步学OpenGL(22) -《OpenGL使用Assimp库导入3d模型》

教程 22

OpenGL使用Assimp库导入3d模型

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial22/tutorial22.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

通过之前的学习我们实现了很多不错的效果,但是我们并不能手能创建复杂的模型,可以想象通过代码来定义物体的每个顶点位置和其他属性是不可行的。一个盒子,金字塔或者简单的表面贴图还好说,但如果是立体的人脸怎么办?事实上在游戏中,在一些商业的游戏应用中模型的网格是由美工艺术家使用一些建模软件创建的,例如:Blender,Maya,3ds Max等。这些软件提供了强大的工具来帮助美工创建复杂的模型。模型创建好之后会保存到一个文件中,3d模型文件有很多格式,例如:OBJ格式。3d模型文件包含了模型的整个几何结构定义,然后可以导入到游戏引擎中(当然游戏引擎要能够支持该模型的格式),通过模型文件可以解析出顶点和顶点缓冲数据用于渲染。理解这些模型文件的几何定义方式以及解析方法从而加载专业的模型对将3d游戏程序提升到另一个级别是非常关键的。

自己开发一个模型解析器程序是很花时间的,因为如果想要加载不同类型的模型资源,你要学习每一种格式的内部原理并分别对其写专门的解析程序。有一些格式是很简单的但有一些模型的格式非常复杂,导致你会在这种并不是3d图形编程的重点部分上浪费大量时间精力。

因此这个教程中的方法就是使用一个外部的框架来解析加载不同的模型文件。

Assimp(Open Asset Import Library)是一个处理很多3D格式文件的开源库,包括最流行的3d格式,在Linux和Windows系统都可以很方便的使用。这个模型解析库可以很容易的整合到C/C++程序中使用。

这个教程中没有太多的理论介绍,我们直接看怎样将Assimp整合到我们的3D程序中

首先要安装Assimp:先点上面的链接去下载后安装该库。

源代码详解

(mesh.h:50)

class Mesh
{
public:
    Mesh();

    ~Mesh();

    bool LoadMesh(const std::string& Filename);

    void Render();

private:
    bool InitFromScene(const aiScene* pScene, const std::string& Filename);
    void InitMesh(unsigned int Index, const aiMesh* paiMesh);
    bool InitMaterials(const aiScene* pScene, const std::string& Filename);
    void Clear();

#define INVALID_MATERIAL 0xFFFFFFFF

    struct MeshEntry {
        MeshEntry();

        ~MeshEntry();

        bool Init(const std::vector& Vertices,
        const std::vector& Indices);

        GLuint VB;
        GLuint IB;
        unsigned int NumIndices;
        unsigned int MaterialIndex;
    };

    std::vector m_Entries;
    std::vector m_Textures;
};

这个Mesh类表示的是Assimp框架和我们的OpenGL程序的接口,这个类的对象使用模型文件名作为其LoadMesh()函数的参数,加载模型然后创建模型中包含的且我们的程序能够理解的顶点缓冲,索引缓冲和纹理对象数据。

使用Render()函数来渲染模型网格,Mesh类的内部结构和Assimp加载模型的方式是刚好匹配的。Assimp使用一个aiScene对象来表示加载的mesh网格,aiScene对象中包含了网格结构,且这个网格结构部分封装了模型。aiScene对象中至少包含一个网格结构,而复杂的模型就可能包含多个网格结构了。m_Entries是Mesh类的一个成员变量,是MeshEntry结构体中的一个向量。Mesh类中的每一个结构体都对应于aiScene对象中的一个mesh结构体,结构体中包含了顶点缓冲,索引缓冲以及材质的索引。目前,材质就指的是贴图纹理了,而网格实体是可以共享材质的因此我们还要为每个纹理(m_Textures)分别设置相应的向量。MeshEntry::MaterialIndex就指向m_Textures中的其中一个纹理。

(mesh.cpp:77)

bool Mesh::LoadMesh(const std::string& Filename)
{
    // Release the previously loaded mesh (if it exists)
    Clear();

    bool Ret = false;
    Assimp::Importer Importer;

    const aiScene* pScene = Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs | aiProcess_JoinIdenticalVertices);

    if (pScene) {
        Ret = InitFromScene(pScene, Filename);
    }
    else {
        printf("Error parsing '%s': '%s'\n", Filename.c_str(), Importer.GetErrorString());
    }

    return Ret;
}

从这个函数开始就要加载mesh了。我们在栈上创建了Assimp::Importer类的一个实例,并调用其ReadFile方法来读取文件。这个函数有两个参数:模型文件的完整路径和一些处理选项。Assimp能对加载的模型进行很多优化操作。例如,为缺失法线的模型生成法线,优化模型的结构以提高性能等,这里列举了所有的优化操作选项,我们可以根据需要来选择合适的操作选项:

  • aiProcess_Triangulate 它将非由三角图元组成的模型转换为三角形图元网格模型。例如:一个四边形mesh可以通过将每个四边形图元分成两个三角形图元而转换成三角形图元mesh;
  • aiProcess_GenSmoothNormals 为那些原来没有顶点法线的模型生成顶点法线。
  • aiProcess_FlipUVsv ,沿着y轴来翻转纹理坐标。这个是用来在demo中正确渲染Quake模型的。
  • aiProcess_JoinIdenticalVertices 使用每个顶点的一份拷贝,并通过索引获取其引用,需要的时候可以帮助节省内存。

注意这些加工方式是非重叠的位掩码,可以使用或运算将多个这些操作组合起来一起用,当然要根据导入的模型数据来选择合理的选择这些操作的。如果mesh加载成功,我们则可以获得一个指向aiScene对象的指针。这个对象包含整个模型的内容,并分布在模型不同的aiMesh结构中。然后我们调用InitFromScene()函数来初始化这个Mesh对象。

(mesh.cpp:97)

bool Mesh::InitFromScene(const aiScene* pScene, const std::string& Filename)
{
    m_Entries.resize(pScene->mNumMeshes);
    m_Textures.resize(pScene->mNumMaterials);

    // Initialize the meshes in the scene one by one
    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
        const aiMesh* paiMesh = pScene->mMeshes[i];
        InitMesh(i, paiMesh);
    }

    return InitMaterials(pScene, Filename);
}

初始化Mesh对象,我们得要分配mesh对象的内存空间,还要准备我们要用的纹理向量以及所有的网格数据和材质。分配空间的大小可以分别从aiScene对象的mNumMeshes和mNumMaterials成员变量中获取相应数量参数。然后扫描aiScene对象的mMeshes数组并依次初始化mesh实体对象。最后就可以返回初始化后的材质了。

(mesh.cpp:111)

void Mesh::InitMesh(unsigned int Index, const aiMesh* paiMesh)
{
    m_Entries[Index].MaterialIndex = paiMesh->mMaterialIndex;

    std::vector Vertices;
    std::vector Indices;
    ...

初始化开始先记录下当前mesh的材质索引,通过索引在渲染期间为mesh网格绑定合适的纹理。然后创建两个STL向量容器来储存顶点和索引缓冲器的数据。STL向量容器有一个很好的特性:能够在连续的缓冲区中储存数据,这样使用glBufferData()函数就很容易将数据加载到OpenGL缓存中了。

(mesh.cpp:118)

    const aiVector3D Zero3D(0.0f, 0.0f, 0.0f);

    for (unsigned int i = 0 ; i < paiMesh->mNumVertices ; i++) {
        const aiVector3D* pPos = &(paiMesh->mVertices[i]);
        const aiVector3D* pNormal = &(paiMesh->mNormals[i]) : &Zero3D;
        const aiVector3D* pTexCoord = paiMesh->HasTextureCoords(0) ? &(paiMesh->mTextureCoords[0][i]) : &Zero3D;

        Vertex v(Vector3f(pPos->x, pPos->y, pPos->z),
                Vector2f(pTexCoord->x, pTexCoord->y),
                Vector3f(pNormal->x, pNormal->y, pNormal->z));

        Vertices.push_back(v);
    }
    ...

这里我们通过解析模型数据将顶点属性数据依次存放到Vertices容器中。这里使用到 aiMesh类中下面的一些方法:

  • mNumVertices: 顶点数量
  • mVertices: 包含位置属性的数组
  • mNormals: 包含顶点法线属性的数组
  • mTextureCoords: 包含纹理坐标数组,这是一个二维数组,因为每个顶点可以拥有多个纹理坐标。

总的来说我们有三个相互独立的数组,囊括了所有我们需要的顶点信息,我们可以通过这些信息来构建我们最终的顶点结构体。注意一些模型没有纹理坐标,在访问mTextureCoords数组之前我们应该通过调用HasTextureCoords()来检查纹理是否存在防止出错。此外,一个 mesh的每个顶点是可以包含多个纹理坐标的,这里我们只是简单地使用第一个纹理坐标。因此mTextureCoords二维数组始终只有第一行的值会被访问。如果纹理坐标不存在,我们就将这个顶点的纹理坐标初始化为零向量。

(mesh.cpp:132)

    for (unsigned int i = 0 ; i < paiMesh->mNumFaces ; i++) {
        const aiFace& Face = paiMesh->mFaces[i];
        assert(Face.mNumIndices == 3);
        Indices.push_back(Face.mIndices[0]);
        Indices.push_back(Face.mIndices[1]);
        Indices.push_back(Face.mIndices[2]);
    }
    ...

接下来我们创建索引缓存。aiMesh类的成员mNumFaces会告诉我们有多少个多边形,而 mFaces数组包含了顶点的索引。首先我们要确保每个多边形的顶点数都为3(加载模型的时候会要求进行三角化,但之后最好再检查确认一下防止意外)。然后我们从模型数据中解析出每个面的索引并将其存放到Indices向量中。

(mesh.cpp:140)

    m_Entries[Index].Init(Vertices, Indices);
}

最后,我们用顶点和索引向量完成MeshEntry结构体的初始化。函数MeshEntry::Init() 中没有添加新内容所以这里就不再介绍,它不过是使用glGenBuffer(),glBindBuffer()和glBufferData()来创建和添加顶点缓存和索引缓存数据。这个可以在源码中看到更多实现细节。

(mesh.cpp:143)

bool Mesh::InitMaterials(const aiScene* pScene, const std::string& Filename)
{
    for (unsigned int i = 0 ; i < pScene->mNumMaterials ; i++) {
        const aiMaterial* pMaterial = pScene->mMaterials[i];
       ...

这个函数用来加载模型所用的所有纹理。在aiScene对象中mNumMaterials属性存放材质数量,而mMaterials是一个指针数组,其中的每一个元素都指向一个aiMaterials结构体。aiMaterials结构体十分复杂,但是它通过几个API函数进行了封装隐藏了复杂的细节。 总体来说,材质是以一个纹理的栈结构来组织的,在连续的纹理之间要应用配置好了的颜色混合以及强度函数。例如:通过混合函数可以知道应该从两张纹理中采集颜色,强度函数可能会要将最终结果再减半(参数为0.5)。颜色混合和强度函数属于aiMaterial结构体的一部分,可以从中调用。这里为了简单以及让我们的光照模型着色器效果明显,我们暂时直接忽略颜色混合和强度函数,直接用原本的纹理。

(mesh.cpp:165)

        m_Textures[i] = NULL;
        if (pMaterial->GetTextureCount(aiTextureType_DIFFUSE) > 0) {
            aiString Path;

            if (pMaterial->GetTexture(aiTextureType_DIFFUSE, 0, &Path, NULL, NULL, NULL, NULL, NULL) == AI_SUCCESS) {
                std::string FullPath = Dir + "/" + Path.data;
                m_Textures[i] = new Texture(GL_TEXTURE_2D, FullPath.c_str());

                if (!m_Textures[i]->Load()) {
                    printf("Error loading texture '%s'\n", FullPath.c_str());
                    delete m_Textures[i];
                    m_Textures[i] = NULL;
                    Ret = false;
                }
            }
        }
        ...

一个材质是可以包含多个纹理的,但并不是所有的纹理都必须包含颜色。例如,一个纹理可以是高度图、法向图、位移图等。当前我们针对光照计算的着色器程序只使用一个纹理,而我们也只关心漫反射纹理,因此我们使用aiMaterial::GetTextureCount()函数检查有多少漫反射纹理存在。这个函数以纹理类型为参数同时返回此特定类型纹理的数目。如果至少存在一个漫反射纹理,我们就可以使用aiMaterial::GetTexture()函数来获取它。这个函数的第一个参数是类型,之后是纹理索引,然后我们需要一个指向纹理文件路径的字符串指针。最后有 5 个指针参数允许我们去获取纹理的各种配置,比如混合因子、全图模式和纹理操作等。这些是可选的,现在我们忽略它们而只传递 NULL。这里我们假定模型和纹理在同一子目录中。如果模型的结构比较复杂,你可能需要在别处寻找纹理,那样的话我们可以像往常一样创建纹理对象并加载它。

(mesh.cpp:187)

      	if (!m_Textures[i]) {
          m_Textures[i] = new Texture(GL_TEXTURE_2D, "../Content/white.png");
          Ret = m_Textures[i]->Load();
       }
    }

    return Ret;
}

上面这一小段代码用于处理模型加载时遇到的一些问题。有时候一个模型可能并没有纹理导致可能会看不到任何东西,因为若纹理不存在取样的结果默认为黑色。这里当我们遇到这种问题时我们为其加载一个白色的纹理(附件中可以找到这个纹理),这样所有像素的基色就变为白色了,看起来不是很好,但至少可以看到一些内容。这张纹理占用空间很小,可以在两个例子中相同的着色器中使用。

(mesh.cpp:197)

void Mesh::Render()
{
    glEnableVertexAttribArray(0);
    glEnableVertexAttribArray(1);
    glEnableVertexAttribArray(2);

    for (unsigned int i = 0 ; i < m_Entries.size() ; i++) {
        glBindBuffer(GL_ARRAY_BUFFER, m_Entries[i].VB);
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 0);
        glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)12);
        glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20);

        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_Entries[i].IB);

        const unsigned int MaterialIndex = m_Entries[i].MaterialIndex;

        if (MaterialIndex < m_Textures.size() && m_Textures[MaterialIndex]) {
            m_Textures[MaterialIndex]->Bind(GL_TEXTURE0);
        }

        glDrawElements(GL_TRIANGLES, m_Entries[i].NumIndices, GL_UNSIGNED_INT, 0);
    }

    glDisableVertexAttribArray(0);
    glDisableVertexAttribArray(1);
    glDisableVertexAttribArray(2);
}

这个函数封装了mesh的渲染,并将其从主函数中分离出来(以前是主函数的一部分)。它遍历m_Entries数组,将数组中每个节点的顶点缓冲和索引缓冲绑定在一起。节点的材质索引用来从m_Texture数组中取出纹理对象,并将这个纹理绑定,最后执行绘制命令。现在有了多个已从文件中加载进来的mesh对象,调用Mesh::Render()函数就可以依次渲染它们了。

(glut_backend.cpp:112)

glEnable(GL_DEPTH_TEST);

最后要需要学习的是以前章节省略的。事实上如果继续使用上面的代码导入模型并渲染,场景可能会出现异常,原因是距离相机较远的三角形被绘制在了距离较近的三角形的上面。为了解决这个问题,需要开启深度测试(Depth test),这样光栅化程序就可以比较屏幕上相同位置重叠像素的深度优先顺序,最后被绘制到屏幕上的就是深度测试后优先绘制(距离相机较近)的像素。深度测试默认不开启,上面的代码用于开启深度测试(这段代码在 GLUTBackendRun()函数中,用于OpenGl状态的初始化),不过这只是开启深度测试的第一步。(下面继续…)

(glut_backend.cpp:73)

glutInitDisplayMode(GLUT_DOUBLE|GLUT_RGBA|GLUT_DEPTH);

这段代码则是对深度缓存的初始化,为了比较两个像素的深度,“旧”的像素必须被储存起来,为此,我们创建一个特殊的缓冲——深度缓冲(或叫Z缓冲器)。深度缓冲的大小与屏幕尺寸对应,这样颜色缓冲器里面的每个像素在深度缓冲器都有相应的位置,这个位置总是储存离相机最近的像素的深度值,用于在深度测试时进行比较。

(tutorial22.cpp:101)

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

The last thing we need to do is to clear the depth buffer at the start of a new frame. If we don’t do that the buffer will contain old values from the previous frame and the depth of the pixels from the new frame will be compared against the depth of the pixels from the previous frame. As you can imagine, this will cause serious corruptions (try!). The glClear() function takes a bitmask of the buffers it needs to operate on. Up until now we’ve only cleared the color buffer. Now it’s time to clear the depth buffer as well. 最后需要做的是在开始渲染新的一帧的时候清除深度缓存,如果不这样做,深度缓存中将会保留上一帧中各像素的深度值,并且新一帧像素的深度会和上一帧像素的深度比较。可以想象,这会导致最后绘制出来的图象很奇怪没法看了(可以试试!)。glClear()函数接收一个它要处理的缓冲器的位掩码。之前只清除了颜色缓存,现在同时还要将深度缓存也清除掉。

关于Objective-C这门语言

问题: 关于Swift和Objective-C语言?

Objective-C是用来开发OS X和iOS软件的最原始语言,是C语言的一个超集,具有面向对象的语言特性,同时提供强大的runtime运行时动态语言特性。OC保留继承了C语言的语法、基本类型和流控制等,又在此基础上添加了定义类和方法的新的语法,具有动态类型、动态绑定和动态加载等动态特性,将一些工作推迟到运行时,大大增强了编程的灵活性,也使其更加强大。

Swift是一种新型语言,用于开发iOS、OS X、watchOS和tvOS应用,它充分继承了C和OC语言的优点同时摆脱了C语言的兼容性问题。Swift采用安全的编程模式,添加了一些新的特性,使编程更加简单、灵活和有趣。OC程序员可以很轻松的转移到Swift上来,因为两者十分具有共性,同时Swift对于新手程序员也比较容易学习入门。 ***

问题: Objective-C的优缺点:

优点:

  • Objective-C是C语言的超集,在C的基础上衍生了很多新的语言特性,封装得很完善方便使用,大大降低了编程复杂度,因此开发中使用起来会感觉方便高效;
  • Category类别的使用,可以快速扩展类的方法,同时使扩展的功能模块之间互不影响;
  • Posing扮演特性,[ParentClass poseAs: [ChildrenClass class]];该语言特性使得父类无需定义和初始化子类对象,即可通过父类扮演子类进行操作;
  • 动态语言特性:动态类型、动态绑定和动态加载等,将类型确定、函数调用和资源加载等任务推迟到运行时,大大提高了编程灵活度;
  • 指针:OC保留了C强大的的指针特性;
  • Objective-C与C/C++可在.mm文件中进行混合编程;


缺点:

  • 不支持命名空间(都是通过加一些像NS或者UI这样的命名前缀来达到用命名空间防止命名冲突的作用,但会使变量命名更长);
  • 不支持运算符重载;
  • 不支持多重继承(C++中通过virtual防止二义性的出现实现多重继承);
  • 使用动态运行时类型,所有的方法都是通过消息传递机制函数调用,有其动态的优势的同时也使很多编译时的优化方法无法使用降低了性能,例如:内联函数等。

问题: OC中的类方法和实例方法有什么本质区别和联系?

首先要明确OC中的类对象和实例对象,开发中定义的类自身也是一个对象,称为类对象,保存该类的成员变量、属性列表和方法列表等。类对象经alloc和init实例化后成为实例对象。

  • 类方法属于类对象,用’+‘号修饰,类似于C语言中的静态方法,类方法列表定义在类对象的元类中,通过isa指针找到;实例方法属于实例对象,用‘-’号修饰,实例方法列表定义在实例对象的类对象中,通过isa指针找到;
  • 类方法只能通过类对象调用,也就是类名直接调用,实例方法则需要由通过alloc和init方法实例化后的实例对象调用;
  • 类方法中的self指的是类对象,实例方法中的self指的是实例对象;
  • 类方法可以调用其他的类方法,但不可以直接调用实例方法,而实例方法则既可以调用其他实例方法,也可以通过类名直接调用本类或者外部类的类方法;
  • 在实例方法中可以访问成员变量,但类方法中不能访问成员变量;

问题:一个OC对象的isa的指针指向什么?有什么作用?

这里写图片描述

OC实例对象的isa指针是指向它的类对象的。OC中有三个层次的对象,实例对象(instance object)、类对象(class)和元类(meta class)。Class即我们定义的类,是实例对象的类对象,而类对象又是其对应元类的实例对象。

通过isa指针可以找到对应类对象或元类中的方法(对象可接收的方法列表),例如,实例对象可以找到其类对象中的实例方法,Class对象可以从元类中找到它的类方法。

【实例对象】–isa->【Class对象:实例方法】–isa->【元类对象:类方法】


问题: 一个objc对象如何进行内存布局?(考虑有父类的情况)

所有父类的成员变量和自己的成员变量都会存放在该对象所对应的存储空间中. 每一个对象内部都有一个isa指针,指向他的类对象,类对象中存放着本对象的 对象方法列表(对象能够接收的消息列表,保存在它所对应的类对象中) 成员变量的列表, 属性列表, 它内部也有一个isa指针指向元对象(meta class),元对象内部存放的是类方法列表,类对象内部还有一个superclass的指针,指向他的父类对象。

每个 Objective-C 对象都有相同的结构,如下图所示:


问题: 子类初始化时为什么要调用self = [super init]?

子类继承自父类,需要获得父类的实例和方法等,因此初始化子类之前要先保证父类已经初始化完毕,防止出错。调用self = [super init]方法,如果父类初始化不成功,会返回nil,因此我们就可以根据self是否为nil判断父类是否初始化成功,从而进行合理的处理,起到容错效果。 ***

问题: 当我们使用dealloc函数释放对象时,为什么一定要调用[super dealloc]方法?在何处调用?

因为子类的很多实例变量是继承自父类的,因此要调用[super dealloc]方法来释放从父类继承来的实例变量,实际上还是释放自己的实例变量,只是继承来的这部分需要调用父类的dealloc方法来释放。

按照自下往上的逻辑,一般我们是要先释放子类的实例,然后再释放父类的实例,因此[super dealloc]方法放在最后调用一下即可。


Objective-C中的MVC模式

问题: 如何理解MVC设计模式?MVC有什么特性?为什么在iPhone开发中被广泛应用?

MVC是iOS开发中一种很基础的工程架构模式,也是构建iOS应用的标准模式。它将数据模型、UI视图和逻辑控制器分开并规定他们之间的通信方式,大大优化了程序的结构组织。

M 表示Model,专门用来存储对象数据的模型,一般使用一个继承NSObject的基本类对模型的数据进行封装,在.h文件中声明一些用来存放数据的属性。在Core Data中模型即Managed object;

V 表示View的可见元素,展示UI界面给用户,主要为UIKit中UIView的子类,其中和用户进行交互的视图元素为UIKit子类UIControl下的子类视图,非UIControl子类的视图不能交互;

C 表示Controller,逻辑控制器,对应于UIKit中UIViewController及其子类控制器,负责协调View和Model。

Controller和View的通信主要通过一些代理协议以及block代码块等实现;而Controller和Model的通信主要用到Notification消息通知和KVO等典型观察者模式实现;View和Model是隔离的不可以直接相互通信。

MVC模式的缺点:

  • 大量逻辑处理代码全部放入ViewController控制器中,加上要遵循很多协议,会导致其变得臃肿和混乱,导致难以维护和管理,也难以分离模块进行测试;
  • 缺少专门放网络逻辑代码的部分,导致网络逻辑处理也只能放在Controller控制器中,加剧了Controller控制器部分的臃肿问题。

问题: 如何理解MVVM设计模式?

MVVM(View-ViewManger-C-ViewModel-Model)

MVVM模式的目的是帮MVC模式中的Controller瘦身,将数据加工的任务从Controller中解放了出来,使得Controller只需要专注于数据调配的工作,让MMVM中的ViewModel去负责数据加工并通过通知机制让View响应ViewModel的改变。MVVM其实就是将MVC中的Controller分出一个专门用来处理的数据的部分后得到的结构模式。

png

  • View -用来呈现用户界面
  • ViewManger -用来处理View的常规事件,负责管理View
  • Controller -负责ViewManger和ViewModel之间的绑定,负责控制器本身的生命周期。
  • ViewModel -存放各种业务逻辑和网络请求
  • Model -用来呈现数据

IOS开发回顾展望

iPhone和iPad

软件支持

iOS

  • 2007年发布
  • 2008年发布开发者SDK
  • iOS当前的版本为:10.2
  • 官方开发者论坛:Apple Developer Forums
  • 官方开发者教程文档资源库:Resources

硬件支持

  • A10处理器(iPhone7/7+)
  • A9处理器(iPhone6s/6s+)
  • A8处理器(iPhone6/iPhone6+)
  • A8X处理器(iPad Air2)
  • A7处理器(iPad Mini3),A7开始处理器为64位
  • 运动辅助处理器(iPhone5s和iPad Air之后)
  • 3D Touch(iPhone6s/6s+之后)
  • 亮度传感器
  • 靠近设备感应器
  • 多点触控(multi-touch)屏幕
  • 加速器
  • 数字罗盘
  • 三轴陀螺仪
  • 辅助GPS(AGPS)
  • 气压计(iPhone 6和iPad Air2之后)
  • 指纹传感器(iPhone 5s和iPad Air2之后)
  • 压力传感器(iPhone 6s and iPhone 7)
  • 触觉反馈引擎(iPhone 6s and iPhone 7)
  • 前后摄像头(模式可调分辨率)

Apple Watch和Apple TV

Apple Watch

软件支持

  • WatchOS(最前版本WatchOS 3.0)

    硬件支持

  • 处理器
    • 苹果S1单片机
    • 苹果S2单片计算机
  • 传感器
    • 环境光传感器
    • 加速传感器和陀螺仪
    • 心率传感器
    • GPS(只支持Series2)
  • 数据连接
    • 蓝牙4.0(LTE)
    • Wifi 802.11b/g/n 2.4 GHz(仅限在系统中使用)
  • 充电
    • 电感应充电

Apple TV(2015)

软件支持

  • Apple tvOS
  • SDK available for Apps Development

硬件支持

  • 处理器(A8)
  • 遥控传感器
    • 加速器和陀螺仪
    • 触摸传感器
    • Siri麦克风
  • 数据连接
    • 蓝牙4.0 (LE)
    • 红外线接收器
    • 820.11交流Wifi天线系统
    • 10/100 BASE-T以太网
    • USB-C服务和支持

iOS SDK

2009年iPhone SDK 3.0发布,现在已经更新到iOS SDK 10.0。

iOS SDK 9.0/9.1特性

  • Apple Pay
  • App应用程序扩展
  • Touch ID指纹识别授权认证
  • Metal游戏引擎
  • HealthKit, HomeKit, iPad多任务切换改进功能
  • 3D Touch搜索GameplayKit
  • App应用瘦身
  • 从左到右的语言支持
  • Swift改进

iOS SDK 10.0新特性

  • SiriKit
  • Callkit
    • 集成VOIP
    • 呼叫屏蔽
  • Homekit改进(在控制中心组织Homekit配件)
  • Apple Pay改进
  • 消息应用程序集成
  • Widget改进

iOS技术架构

  • Cocoa Touch框架层
    • UI组件
    • 触摸处理和事件驱动
    • 系统接口
  • Media媒体层
    • 音频视频播放
    • 动画
    • 2d和3d图形
  • Core Servie核心服务层
    • 底层特性
    • 文件
    • 网络
    • 位置服务等
  • Core OS系统层
    • 内存管理
    • 底层网络
    • 硬件管理

新型语言Swift(从OC到Swift)

Swift是一门新型语言,借鉴了Haskell, Ruby, Python, C#等语言特性,看上去偏脚本化,swift仍然支持已有的Cocoa和Cocoa Touch框架。

Swift的主要新特性:

  • 安全【严格的类型检查】
  • 强大【高度优化的LLVM编译器】
  • 新型【借鉴多种语言特性,表达更简单精确】

这里写图片描述

从基本的ViewController代码窥探OC和Swift区别

Swift

// ViewController.swift
import UIKit
class ViewController: UIViewController {
    @IBOutlet weak var label1: UILabel!
    @IBAction func button1(sender: AnyObject) {
        label1.text = "Hello iOS!!!"
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }
    override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
}

Objective-C

// ViewController.h
#import <UIKit/UIKit.h>
@interface ViewController : UIViewController
@property (weak, nonatomic) IBOutlet UILabel *label1; - (IBAction)button1:(id)sender;
@end
// ViewController.m
#import "ViewController.h" 
@interface ViewController () @end
@implementation ViewController @synthesize label1 ;
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
}
- (void)didReceiveMemoryWarning { [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}
- (IBAction)button1:(id)sender {
    label1.text = @"Hello iOS!!!" ;
}
@end

Swift类的定义

整个类文件都定义在一个swift文件内:

import Foundation
class Ball {
	// 变量
    var centerX: Float
    var centerY: Float
    var radius: Float
    // 初始化方法
    init(centerX:Float,centerY:Float,radius:Float) {
        self.centerX = centerX
        self.centerY = centerY
        self.radius = radius
	}
	// 实例方法
    func move(moveX:Float, _ moveY:Float) {
        self.centerX += moveX
        self.centerY += moveY
    }
    // 类方法
    class func aClassMethod() {
          print("I am a class method")
    }
}

...

// 创建对象
var ball1 = Ball(centerX: 7.0, centerY: 5.0, radius: 6.0)
// 方法调用
ball1.move(moveX:1.0, 1.0)
Ball.aClassMethod()

基本数据类型

这里写图片描述

这里写图片描述

流程控制语句

Objective-c

// 条件判断
if (a < b) {
    // Do something here
} else {
    // Do another thing here
}
// for循环
for (int i = 0; i < 10; i++){
    // Do something here
}
// while循环
while (count < 10) {
	// Do something here
}
// do-while循环
do {
    // Do something here
} while (count < 10);

Swift

// 条件判断
if a < b {
    // Do something here
} else {
    // Do another thing here
}
// for循环
for int i = 0; i < 10; i++{
    // Do something here
}
// while循环
while count < 10 {
	// Do something here
}
// repeat-while循环
repeat {
    // Do something here
} while count < 10

String字符串

Objective-C

NSString * Str = @"string"; 
NSString * formatStr = [NSString stringWithFormat:@"%@and float%f", Str, 3.1415"]; 

Swift

// 可变字符串
var Str = "string"
var Str:String = "string"
var Str = String("string")
// 不可变字符串
let Str = "string"
let Str:String = "string"
let Str = String("string")

数组Array和MultableArray

Objective-C

// 静态数组
NSArray *array = [[NSArray alloc] initWithObjects: ball1, ball2, nil];
array[0].radius = 10;
// 可变数组
NSMutableArray *mArray = [[NSMutableArray alloc] initWithCapacity: 2];
[mArray addObject:ball1];
[mArray addObject:ball2];
Ball *newball = [mArray objectAtIndex:1];
[mArray removeObjectAtIndex:1];

Swift

// 静态数组
let myArray: Array<Ball> = [ball1, ball2]
let myArray: [Ball] = [ball1, ball2]
let myArray = [ball1, ball2]
myArray[0].radius = 10

// 可变数组
var myArray: [Ball] = []
myArray.append(ball1)
myArray.append(ball2)
var newBall = myArray[1];
myArray.remove(at: 0)

UIImageView

Objective-C

UIImageView *myImage = [[UIImageView alloc] initWithImage: [UIImage imageNamed:@”tiger.png]];
[self.view addSubview:myImage];
myImage.center = CGPointMake(150, 200);
myImage.frame = CGRectMake(0, 0, 50, 25);

Swift

let myImage = UIImageView(image: UIImage(named: "tiger.png"))
view.addSubview(myImage)
myImage2.frame = CGRect(x:0, y:0, width:50, height:25)
myImage2.center = CGPoint(x:150, y:200)

… …

关于物质意识能量的思考

没有纠结过意识跟物质的关系,觉得此两者的关系并非直接相连,至少不可双向互相转化。但物质和能量、意识和能量的关系就有意思了。物质和能量的关系爱因斯坦的质能方程可以给出一个理性的逻辑诠释(都是能量了),虽然并不能真正去理解,自己的意识形态也根本达不到去真正体会的层次。

一直比较好奇意识和能量的关系,有下面这些疑问:

  • 意识是能控制能量才导致现实世界的能量的运作有目的性?
  • 还是意识本身就是能量,能量的高级形态?
  • 意识是从普通能量中汲取营养塑造自身意识的吗?
  • 意识和意识(相对高级意识和低级意识)之间是互相影响进一步塑造?
  • 意识如果不是普通的能量,那意识脱离能量后会退化为普通能量吗?

由于现实社会的影响,我们都会共同这样去理解自己的生命:每个人的生命都是一样的,都要生老病死,新陈代谢符合自然规律,有生之年不同阶段要努力保持心态乐观享受人生。但是,我想多数人都会偶尔从这其中回过神儿来有过这样短暂的念头:我死后我的意识到底去哪里了,自己会觉得虽然自己跟别人一样,普通的生物,但是意识会挣扎着说自己与众不同(‘自己’这个概念也是太狭窄了,事实是个体和世界所存在的相互矛盾),自己的意识中自己才是自己理解的世界的核心,如果自己的意识死掉那这个世界应该瞬间坍缩。想到这会细思极恐,这也是人最怕死的一个时刻:对意识的终将孤独和将何去何从的未知恐惧(至少这是我最怕死的时刻,这个短暂的时刻会让我觉得人生本身无意义)。

由于没有一个意识能在人死后脱离能量后再回到之前那个世界向其他‘活着’的意识解释结果究竟如何,所以现实世界只剩猜想,只剩玄学思索而永不得解,活着的意识永远都要有着对未知结果的恐慌。

禅中说修禅要六根清净,极少数达到意识至高境界的人可以脱离五感,但我理解不了,所以又有疑惑,意识如果真的脱离了所有的感觉是一种什么样的状态,难道说像霍金那种意识形态极高的人的意识就是人死后意识达到的至高状态?这种人已经接近人死后意识的状态吗?这样死后的所有意识都摆脱束缚回归意识至高境界,现有的很多哲学理论好像都试图去解释这个而且其实都很类似。

在之前的一个阶段我这么理解过意识、能量和物质的关系,现在想想貌似不很合理了:即三者是独立的,意识处于主导地位,意识能够通过能量的介质去控制物质,同时意识借助大脑的机能存储以及读取意识的经验积累(感觉找不到合适的词形容…),意识要通过能量和物质去和其他意识交流,人死后失去了对能量的控制也就无法向活着的意识传达什么…

转换模式…

现实点说,还是要活出自己的个性,坚持自己的独立性,不能活在别人的意识呵护下,要去挑战自己意识的等级极限,从高等级意识的人身上汲取营养获得更好的人生体验。虽然人生太短细思极恐,但当前的年龄段,最要自己去探索去闯荡,去摸爬滚打去爱去疯狂,为以后意识形态的提高打下基础,要不得眼高手低,要不得痴心妄想,要不得二十岁之前的理想主义和个人英雄主义;停止以为自己有多努力就被自己感动的稀里哗啦的行为,妄想以努力程度去划分人的层次的想法是20岁之前最明显的错觉了。此致所有在不同高度不同平台咬牙奋斗的终将一同老去灰飞烟灭的同龄人,站得多高不动人,爬了多远才美丽。

【iOS沉思录】OC中的头文件引用:#import和@class

问题 #import#include有什么区别?

都是用来引入头文件的,但比起#include,Objective-C中#import的优势是不会引起重复包含,相当于多了C/C++中#pragma once的作用,保证头文件只被便编译一次。


问题 #import<>#import" "有什么区别?

在C/C++中也有#include<>和#include”“之分,这里区别一样:使用尖括号<>指的是用来引入系统的头文件的,而使用引号” “则是用来引入本地用户头文件的。


问题 Objective-C中@class代表什么?

@class相当于只是在头文件声明一下要用到的类的头文件(前向声明),告诉编译器有这样一个类的定义但暂时不要将类的实现引入,让该类定义的变量能够编译通过,直到运行起来时才去查看类的实现文件。但实际上这样也只能起到在头文件声明该类实例变量的作用,在.m文件中如果用到类的实现细节(属性和方法)还是要通过#import再次引入类的头文件。

使用@class的好处是将头文件的引入延迟了,至少延迟到了.m实现文件中,这也符合我们追求直到真正用到的时候再确定引入的动态思想,尽量往后拖延,更重要的是这样也可以有效的避免头文件的重复引入甚至循环引用等问题。

Objective-C语言的动态性总结(编译时与运行时)

编译时与运行时

编译时: 即编译器对语言的编译阶段,编译时只是对语言进行最基本的检查报错,包括词法分析、语法分析等等,将程序代码翻译成计算机能够识别的语言(例如汇编等),编译通过并不意味着程序就可以成功运行。

运行时: 即程序通过了编译这一关之后编译好的代码被装载到内存中跑起来的阶段,这个时候会具体对类型进行检查,而不仅仅是对代码的简单扫描分析,此时若出错程序会崩溃。

可以说编译时是一个静态的阶段,类型错误很明显可以直接检查出来,可读性也好;而运行时则是动态的阶段,开始具体与运行环境结合起来

OC语言的动态性

含义

OC语言的动态性主要体现在三个方面:

  • 动态类型(Dynamic typing):运行时确定对象的类型
  • 动态绑定(Dynamic binding):运行时确定对象的调用方法
  • 动态加载(Dynamic loading):运行时加载需要的资源或者可执行代码

动态类型

动态类型指的是对象指针类型的动态性,具体是指使用id任意类型将对象的类型确定推迟到运行时,由赋给它的对象类型决定对象指针的类型。另外类型确定推迟到运行时之后,可以通过NSObject的isKindOfClass方法动态判断对象最后的类型(动态类型识别)。也就是说id修饰的对象为动态类型对象,其他在编译器指明类型的为静态类型对象,通常如果不需要涉及到多态的话还是要尽量使用静态类型(原因上面已经说到:错误可以在编译器提前查出,可读性好)。

示例:

对于语句NSString* testObject = [[NSData alloc] init]; testObject在编译时和运行时分别是什么类型的对象?

首先testObject是一个指向某个对象的指针,不论何时指针的空间大小是固定的。

编译时: 指针的类型为NSString,即编译时会被当成一个NSString实例来处理,编译器在类型检查的时候如果发现类型不匹配则会给出黄色警告,该语句给指针赋值用的是一个NSData对象,则编译时编译器则会给出类型不匹配警告。但编译时如果testObject调用NSString的方法编译器会认为是正确的,既不会警告也不会报错。

运行时: 运行时指针指向的实际是一个NSData对象,因此如果指针调用了NSString的方法,虽然编译时通过了,但运行时会崩溃,因为NSData对象没有该方法;另外,虽然运行时指针实际指向的是NSData,但编译时编译器并不知道(前面说了编译器会把指针当成NSString对象处理),因此如果试图用这个指针调用NSData的方法会直接编译不通过,给出红色报错,程序也运行不起来。

下面给出测试例子:

    /* 1.编译时编译器认为testObject是一个NSString对象,这里赋给它一个NSData对象编译器给出黄色类型错误警告,但运行时却是指向一个NSData对象 */
    NSString* testObject = [[NSData alloc] init];
    /* 2.编译器认为testObject是NSString对象,所以允许其调用NSString的方法,这里编译通过无警告和错误 */
    [testObject stringByAppendingString:@"string"];
    /* 3.但不允许其调用NSData的方法,下面这里编译不通过给出红色报错 */
    [testObject base64EncodedDataWithOptions:NSDataBase64Encoding64CharacterLineLength];

将上面第三句编译不通过的注释掉,然后在第二句打断点,编译后让程序跑起来到断点出会看到testObject指针的类型是_NSZeroData,指向一个NSData对象。继续运行程序会崩溃,因为NSData对象没有NSString的stringByAppendingString这个方法。

那么,假设testObject是id类型会怎样呢?

    /* 1.id任意类型,编译器就不会把testObject在当成NSString对象了 */
    id testObject = [[NSData alloc] init];
    /* 2.调用NSData的方法编译通过 */
    [testObject base64EncodedDataWithOptions:NSDataBase64Encoding64CharacterLineLength];
    /* 3.调用NSString的方法编译也通过 */
    [testObject stringByAppendingString:@"string"];

结果是编译完全通过,编译时编译器把testObject指针当成任意类型,运行时才确定testObject为NSData对象(断点看指针的类型和上面的例子中结果一样还是_NSZeroData,指向一个NSData对象),因此执行NSData的函数正常,但执行NSString的方法时还是崩溃了。通过这个例子也可以很清楚的知道id类型的作用了,将类型的确定延迟到了运行时,体现了OC语言的一种动态性:动态类型。

动态类型识别方法(面向对象语言的内省Introspection特性)

1.首先是Class类型:

  • Class class = [NSObject class]; // 通过类名得到对应的Class动态类型
  • Class class = [obj class]; // 通过实例对象得到对应的Class动态类型
  • if([obj1 class] == [obj2 class]) // 判断是不是相同类型的实例

2.Class动态类型和类名字符串的相互转换:

  • NSClassFromString(@”NSObject”); // 由类名字符串得到Class动态类型
  • NSStringFromClass([NSObject class]); // 由类名的动态类型得到类名字符串
  • NSStringFromClass([obj class]); // 由对象的动态类型得到类名字符串

3.判断对象是否属于某种动态类型:

  • -(BOOL)isKindOfClass:class // 判断某个对象是否是动态类型class的实例或其子类的实例
  • -(BOOL)isMemberOfClass:class // 与isKindOfClass不同的是,这里只判断某个对象是否是class类型的实例,不放宽到其子类

4.判断类中是否有对应的方法:

  • -(BOOL)respondsTosSelector:(SEL)selector // 类中是否有这个类方法
  • -(BOOL)instancesRespondToSelector:(SEL)selector // 类中是否有这个实例方法

区别: 上面两个方法都可以通过类名调用,前者判断类中是否有对应的类方法(通过‘+’修饰定义的方法),后者判断类中是否有对应的实例方法(通过‘-’修饰定义的方法)。此外,前者respondsTosSelector函数还可以被类的实例对象调用,效果等同于直接用类名调用后者instancesRespondToSelector函数。 举个例子:假设有一个类Test,有它的一个实例对象test,Test类中定义了一个类函数:+ (void)classFun;和一个实例函数:- (void)objFunc;,那么各种调用情况的结果如下:

    [1][Test instancesRespondToSelector:@selector(objFunc)];//YES
    [2][Test instancesRespondToSelector:@selector(classFunc)];//NO
    
    [3][Test respondsToSelector:@selector(objFunc)];//NO
    [4][Test respondsToSelector:@selector(classFunc)];//YES
    
    [5][test respondsToSelector:@selector(objFunc)];//YES
    [6][test respondsToSelector:@selector(classFunc)];//NO

结论: 如果想判断一个类中是否有某个类方法,应该使用[4]; 如果想判断一个类中是否有某个实例方法,可以使用[1]或者[5]。

5.方法名字符串和SEL类型的转换

在编译器,编译器会根据方法的名字和参数序列生成唯一标识改方法的ID,这个ID为SEL类型。到了运行时编译器通过SEL类型的ID来查找对应的方法,方法的名字和参数序列相同,那么它们的ID就都是相同的。另外,可以通过@select()指示符获得方法的ID。常用的方法如下:

SEL funcID = @select(func)// 这个注册事件回调时常用,将方法转成SEL类型
SEL funcID = NSSelectorFromString(@"func"); // 根据方法名得到方法标识
NSString *funcName = NSStringFromSelector(funcID); // 根据SEL类型得到方法名字符串

动态绑定

动态绑定指的是方法确定的动态性,建立在动态类型的物质基础之上,具体指的是在OC的消息分发机制支持下将要执行的方法的确定推迟到运行时,可以动态添加方法。也就是说,一个OC对象是否调用某个方法不是在编译期决定的,编译期方法的调用不和代码绑定在一起,而是到了运行时根据发出的具体消息而动态确定要调用的代码。利用动态类型和动态绑定可以实现代码每次执行消息和消息的接收者可能会变化,执行结果不一样;另外与动态绑定相关的还有基于消息传递机制的消息转发机制,主要处理应对一些接收者无法处理的消息,此时有机会将消息转发给其他接收者处理。

动态绑定是基于动态类型的,在运行时对象的类型确定后,那么对象的属性和方法也就确定了,包括类中原来的属性和方法,以及运行时动态新加入的属性和方法。可以通过对象的isa指针到对象的类对象中找到方法列表,即可接收的消息列表。

消息传递机制:

在OC中,方法的调用不再理解为对象调用其方法,而是要理解成对象接收消息,消息的发送采用‘动态绑定’机制,具体会调用哪个方法直到运行时才能确定,确定后才会去执行绑定的代码。方法的调用实际就是告诉对象要干什么,给对象(的指针)传送一个消息,对象为接收者(receiver),调用的方法及其参数即消息(message),给一个对象传消息表达为:[receiver message]; 接受者的类型可以通过动态类型识别于运行时确定。

在消息传递机制中,当开发者编写[receiver message];语句发送消息后,编译器都会将其转换成对应的一条objc_msgSend C语言消息发送原语,具体格式为: void objc_msgSend (id self, SEL cmd, ...)

这个原语函数参数可变,第一个参数填入消息的接受者,第二个参数是消息‘选择子’,后面跟着可选的消息的参数。有了这些参数,objc_msgSend就可以通过接受者的的isa指针,到其类对象中的方法列表中以选择子的名称为‘键’寻找对应的方法,找到则转到其实现代码执行,找不到则继续根据继承关系从父类中寻找,如果到了根类还是无法找到对应的方法,说明该接受者对象无法响应该消息,则会触发‘消息转发机制’,给开发者最后一次挽救程序崩溃的机会。

消息转发机制:

如果消息传递过程中,接受者无法响应收到的消息,则会触发进入‘消息转发’机制。

消息转发依次提供了三道防线,任何一个起作用都可以挽救此次消息转发。按照先后顺序三道防线依次为:

  • 动态补加方法的实现
+ (BOOL)resolveInstanceMethod:(SEL)sel
+ (BOOL)resolveClassMethod:(SEL)sel
  • 直接返回消息转发到的对象(将消息发送给另一个对象去处理)
- (id)forwardingTargetForSelector:(SEL)aSelector
  • 手动生成方法签名并转发给另一个对象
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector;
- (void)forwardInvocation:(NSInvocation *)anInvocation;

这里以一个简单的例子展示消息转发的完整个过程。定义一个Test类,类头文件声明一个名为instanceMethod的实例方法但不提供方法实现(消息转发主要就针对实例方法,类方法由于无法在运行时动态添加实现等事实并不能转发给其他类):

/* Test.h */
@interface Test : NSObject
/* 只声明一个实例方法而不在.m文件中实现 */
- (void)instanceMethod;
@end

然后在main函数中实例化Test对象并调用该实例方法,由于方法没有实现,因此在运行时一定会触发消息转发机制:

/* main.m */
#import "Test.h"
#import <objc/runtime.h>

int main(int argc, const char * argv[]) {
    Test *test = [[Test alloc] init];
    [test instanceMethod];
    return 0;
}

先进入消息转发的第一道防线,我们在Test类的.m文件中提供运行时的转发接应,实现resolveInstanceMethod方法为指定的instanceMethod消息补加对应方法的实现完成补救:

/* Test.m */
#import <objc/runtime.h>
/*
 * 被动态添加的实例方法实现
 */
void instanceMethod(id self, SEL _cmd) {
    NSLog(@"收到消息后会执行此处的函数实现...");
}

/*
 * 动态补加方法实现
 */
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(instanceMethod)) {
        class_addMethod(self, sel, (IMP)instanceMethod, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

如果没有实现resolveInstanceMethod方法就行补救或者直接返回了NO,则进入第二道防线,这里我们要实现forwardingTargetForSelector函数返回另一个实例对象,让该对象代替原对象去处理这个消息。 假设我们让一个叫做Test2的类对象去处理这个消息,Test2类中要有同名的方法和方法的实现,这样就会执行Test2中的同名方法完成消息转发:

/* Test2.h */
@interface Test2 : NSObject
- (void)instanceMethod;
@end

/* Test2.m */
@implementation Test2
- (void)instanceMethod {
    NSLog(@"消息转发到这...");
}
@end

/* Test.m */
- (id)forwardingTargetForSelector:(SEL)aSelector {
    /* 返回转发的对象实例 */
    if (aSelector == @selector(instanceMethod)) {
        return [[Test2 alloc] init];
    }
    return nil;
}

如果没有实现上面的两个补救方法或者forwardingTargetForSelector方法直接返回了nil,则进入最后一道防线,此时我们要手动生成方法签名并实现forwardInvocation方法将消息转发给另一个对象,同第二道防线类似:

/* Test.m */
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    /* 为指定的方法手动生成签名 */
    NSString *selName = NSStringFromSelector(aSelector);
    if ([selName isEqualToString:@"instanceMethod"]) {
        return [NSMethodSignature signatureWithObjCTypes:"v@:"];
    }
    return [super methodSignatureForSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    /* 如果另一个对象可以相应该消息,则将消息转发给他 */
    SEL sel = [anInvocation selector];
    Test2 *test2 = [[Test2 alloc] init];
    if ([test2 respondsToSelector:sel]) {
        [anInvocation invokeWithTarget:test2];
    }
}

动态加载

动态加载主要包括两个方面,一个是动态资源加载,一个是一些可执行代码模块的加载,这些资源在运行时根据需要动态的选择性的加入到程序中,是一种代码和资源的‘懒加载’模式,可以降低内存需求,提高整个程序的性能,另外也大大提高了可扩展性。

例如:资源动态加载中的图片资源的屏幕适配,同一个图片对象可能需要准备几种不同分辨率的图片资源,程序会根据当前的机型动态选择加载对应分辨率的图片,像iphone4之前老机型使用的是@1x的原始图片,而retina显示屏出现之后每个像素点被分成了四个像素,因此同样尺寸的屏幕需要4倍分辨率(宽高各两倍)的@2x图片,最新的针对iphone6/6+以上的机型则需要@3x分辨率的图片。例如下面所示应用的AppIcon,需要根据机型以及机型分辨率动态的选择加载某张具体的图片资源:这里写图片描述


官方运行时源码:https://opensource.apple.com/source/objc4/objc4-532/runtime/

OC运行时编程指南:Objective-C Runtime Programming Guide

Cocoa消息 Cocoa类与对象


问题: 我们所说的Objective-C是动态运行时语言是什么意思?

主要指的是OC语言的动态性,包括动态性和多态性两个方面。

  • 动态性:即OC的动态类型、动态绑定和动态加载特性,将对象类型的确定、方法调用的确定、代码和资源的装载等推迟到运行时进行,更加灵活;
  • 多态:多态是面向对象变成语言的特性,OC作为一门面向对象的语言,自然具备这种多态性,多态性指的是来自不同类的对象可以接受同一消息的能力,或者说不同对象以自己的方式响应相同的消息的能力。

问题: 动态绑定是在运行时确定要调用的方法?

动态绑定将调用方法的确定推迟到运行时。在编译时,方法的调用并不和代码绑定在一起,只有在消实发送出来之后,才确定被调用的代码。通过动态类型和动态绑定技术,代码每次执行都可以得到不同的结果。运行时负责确定消息的接收者和被调用的方法。运行时的消息分发机制为动态绑定提供支持。当向一个动态类型确定了的对象发送消息时,运行环境会通过接收者的isa指针定位对象的类,并以此确定被调用的方法,方法是动态绑定的。 ***

问题: 解释OC中的id类型?id、nil代表什么?

id表示变量或对象的类型在编写代码时(编译期)不确定,视为任意类型,直到程序跑起来推迟到运行时才最终确定其类型。id类似于C/C++中的void *,但id和void*并非完全一样。id是一个指向继承了NSObject的OC对象的指针,注意id是一个指针,虽然省略了*号。id和C语言的void*之间需要通过bridge关键字来显式的桥接转换。具体转换方式示例如下:

id nsobj = [[NSObject alloc] init];
void *p = (__bridge void *)nsobj;
id nsobj = (__bridge id)p;

OC中的nil定义在objc/objc.h中,表示的是一个指向空的Objctive-C对象的指针。例如weak修饰的弱引用对象在指向的对象释放时会自动将指针置为nil,即空对象指针,防止‘指针悬挂’。


问题: instancetype和id的区别?

instancetype和id都可以用来代表任意类型,将对象的类型确定往后推迟,用于体现OC语言的动态性,使其声明的对象具有运行时的特性。

它们的区别是:instancetype只能作为返回值类型,但在编译期instancetype会进行类型检测,因此对于所有返回类的实例的类方法或实例方法,建议返回值类型全部使用instancetype而不是id,具体原因后面举例介绍;id类型既可以作为返回值类型,也可以作为参数类型,也可以作为变量的类型,但id类型在编译期不会进行类型检测。


问题: 一般的方法method和OC中的选择器selector有何不同?

selector是一个方法的名字,基于动态绑定环境下,method是一个组合体,包含了名字和实现。

可以理解@selector()就是取类方法的编号,他的行为基本可以等同C语言的中函数指针,只不过C语言中,可以把函数名直接赋给一个函数指针,而Objective-C的类不能直接应用函数指针,这样只能做一个@selector语法来取. 它的结果是一个SEL类型。这个类型本质是类方法的编号(函数地址)。 ***

问题:什么是目标-动作(target-action)机制?

目标是动作消息的接收者。例如一个控件,或者更为常见的是它的单元, 以插座变量的形式保有其动作消息的目标。 动作是控件发送给目标的消息,或者从目标的角度看,它是目标为了响应动作而实现的方法。程序需要某些机制来进行事件和指令的翻译。这个机制就是目标-动作机制。 ***

问题:下列代码取决于Objective-C的哪样特性?

id myobj;
... ...
[myobj draw];
  • 预处理机制
  • 枚举数据类型
  • 静态类型
  • 动态类型(right)

问题: Object-C有私有方法吗? 私有变量呢?

首先要看私有的含义。私有主要指的是通过类的封装性,将不希望让外界看到的方法或属性隐藏在类内部,只有该类可以在内部访问,外部不可见不可访问。

表面上OC中是可以实现私有的变量和方法的,即将它们隐藏不暴露在头文件,不可以显式地直接访问,但是OC中这种私有并不是绝对的私有,例如即使将变量和方法隐藏在.m实现文件中,开发者仍然可以利用runtime运行时机制强行访问没有暴露在头文件的变量和方法。

OC中实现变量和方法‘私有‘的方式: 一种是在类的头文件中生命私有变量:

#import <Foundation/Foundation.h>

@interface Test : NSObject {
    /* 头文件中定义私有变量,默认为@protected */
    @private
    NSString *major;
}

@end

另外一种是在.m实现文件头部的类扩展区域定义私有属性或方法,其中方法可不用声明,直接在实现文件中实现即可,只要不在头文件生命的方法都对外不可见:

#import "Test.h"
@interface Test() {
    /* 类扩展区域定义私有变量,默认就是@private */
    int age;
}

/* 类扩展区域定义私有属性 */
@property (nonatomic, copy) NSString *name;

/* 类扩展区域定义私有实例方法(可省略声明,类方法的作用主要就是提供对外接口的,所以一般不会定义为私有) */
- (void)test;

@end

@implementation Test

/**
 * 私有实例方法
 */
- (void)test {
    NSLog(@"这是个私有实例方法!");
}
@end

一步步学OpenGL(21) -《聚光灯光源》

教程 21

聚光灯光源

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial21/tutorial21.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

聚光灯光源是目前这里要介绍的第三种也是最后一种光源类型了,它比平行光和点光源要复杂,但聚光灯光源其实是具有平行光和点光源核心特征的一种特殊光源。聚光灯光源也会随着距离衰减,但它不是像点光源照向四面八方的而是像平行光那样有一个聚光方向(相当于取点光源的一个锥形的一小部分),聚光灯光源呈锥形,因此有一个新的属性,就是离光源越远,照亮的圆形区域会越大(光源位于锥形体的尖端)。聚光灯光源,顾名思义,对应于现实中的聚光灯,例如:手电筒。在游戏中,聚光灯主要用于某些场景,例如:主角拿着手电筒在黑暗的地道里探索或者逃离监狱。

我们已经知道了创建聚光灯光源的所有技术,这里最后还要另外学一下如何实现这个光源类型的锥形效果。如下图:

http://ogldev.atspace.co.uk/www/tutorial21/spotlight.png

图中垂直指向地面的黑色尖头指的是光源方向,这里想实现让光源只照亮两条红线夹角之间的区域,这里仍然可以使用点积来实现。我们可以定义光锥为光线方向L和红线之间的那个夹角(两条红线之间夹角的一半)。计算那个夹角的余弦值‘C’(点积计算得到)以及L和V夹角的余弦,其中V指的是光源到某个像素的向量,如果后者的值大于余弦值‘C’(夹角越小余弦越大),说明L和V之间的夹角偏小,该像素就位于被照亮的区域内。反之,像素位于区域外就不会被该光源照亮。

如果我们紧紧按照上面说的在照亮区域内就点亮像素,否则就不点亮,那样就会看上去非常假,因为照亮区域和未照亮区域之间的边界边缘会非常明显(没有一个自然的过渡),看上去会是一个清晰的圆形画在一个黑色区域(如果没有其他光源的话)。一个真实的聚光灯光源会从照亮区域的中心向圆形边缘慢慢衰减。这里我们可以利用上面计算得到的那些点积作为一个衰减的参数。首先我们知道,当L和V两个向量相等重合时,点积为‘1’。但是用余弦来做衰减参数会有问题,因为聚光灯光源的夹角不能太大,否则范围太广就失去了聚光灯的效果,但是在夹角从0到一个比较小的角度范围内,cos值得变化是很缓慢的,导致衰减不明显。例如:让聚光灯的夹角为20度,余弦值就为0.939,[0.939,1.0]这个变化范围就不好作为衰减参数了,在这个范围内进行插值的空间不足,造成的衰减程度不足以让眼睛察觉到。要想衰减效果明显这个参数范围应该是[0,1]。解决方法是将这个参数的小范围映射到[0,1]的范围,方法如下:

http://ogldev.atspace.co.uk/www/tutorial21/map.png

原理很简单:计算大的范围和小的范围的比例,然后根据那个比例对小范围进行映射扩张即可。

源代码详解

(lighting_technique.h:68)

struct SpotLight : public PointLight
{
    Vector3f Direction;
    float Cutoff;

    SpotLight()
    {
        Direction = Vector3f(0.0f, 0.0f, 0.0f);
        Cutoff = 0.0f;
    }
};

聚光灯光源的结构体继承自点光源的结构体,并添加了两个属性和点光源区别开:一个是光源的方向向量,另一个是截断光源照亮范围的一个阈值。阈值代表的是光源方向向量和光源到可照亮像素之间的最大夹角。比这个阈值夹角大的像素是不会被该光源照亮的。这里还在LightingTechnique类中为shader添加了一个位置数组,用来获取shader中的聚光灯光源数组。

(lighting.fs:39)

struct SpotLight
{
    struct PointLight Base;
    vec3 Direction;
    float Cutoff;
};
...
uniform int gNumSpotLights;
...
uniform SpotLight gSpotLights[MAX_SPOT_LIGHTS];

在GLSL中有一个聚光灯光源类型的类似的结构体。由于这里我们不能够在C++代码中进行继承,所以这里将一个点光源结构体对象作为一个成员对象变量,并在后面添加新的属性。有一个不一样的地方是在C++代码中那个阈值是夹角本身,而在shader中这个阈值是那个夹角的余弦值。shader着色器只关心夹角的余弦值,因此计算一次并存储比为每一个像素都重新计算余弦值要高效得多。这里还定义了一个聚关灯光源的数组,并使用一个叫做’gNumSpotLights’的计数器来限制允许应用去创建使用的聚光灯光源的数量。

(lighting.fs:85)

vec4 CalcPointLight(struct PointLight l, vec3 Normal)
{
    vec3 LightDirection = WorldPos0 - l.Position;
    float Distance = length(LightDirection);
    LightDirection = normalize(LightDirection);

    vec4 Color = CalcLightInternal(l.Base, LightDirection, Normal);
    float Attenuation = l.Atten.Constant +
        l.Atten.Linear * Distance +
        l.Atten.Exp * Distance * Distance;

    return Color / Attenuation;
}

点光源的函数有了轻微的改动:将一个点光源的结构体作为一个参数,而不是直接获取全局数组。这样更容易将它分享给聚光灯光源对象使用。其他的这里没有做改动。

(lighting.cpp:fs)

vec4 CalcSpotLight(struct SpotLight l, vec3 Normal)
{
    vec3 LightToPixel = normalize(WorldPos0 - l.Base.Position);
    float SpotFactor = dot(LightToPixel, l.Direction);

    if (SpotFactor > l.Cutoff) {
        vec4 Color = CalcPointLight(l.Base, Normal);
        return Color * (1.0 - (1.0 - SpotFactor) * 1.0/(1.0 - l.Cutoff));
    }
    else {
        return vec4(0,0,0,0);
    }
}

这里这个函数是我们计算聚光灯光源效果的地方。首先得到光源到某个像素的向量,将向量单位化方便点积运算,然后和单位化了的光源方向向量进行点积运算得到他们之间夹角的余弦值。将得到的余弦值和光源的阈值(定义光源范围的最大夹角的余弦值)进行比较,如果余弦值比阈值小,说明夹角太大像素在照亮圆区域的外面,这样像素就不会被该光源点亮。这样那个阈值就可以将聚光灯光源的照亮范围限制在一个大的或者小的圆圈内。反之如果像素在照亮区域内,我们就先像点光源那样计算光源的基础颜色。然后将那个点积计算得到的参数’SpotFactor’放到上面的公式中,将这个参数线性插值到0到1的范围,最后和点光源颜色相乘计算得到最终的聚光灯颜色值。

(lighting.fs:122)

...
for (int i = 0 ; i < gNumSpotLights ; i++) {
    TotalLight += CalcSpotLight(gSpotLights[i], Normal);
}
...

和点光源的计算模式一样我们在主函数通过循环遍历累加所有聚光灯光源的效果得到对应像素的最终颜色值。

(lighting_technique.cpp:367)

void LightingTechnique::SetSpotLights(unsigned int NumLights, const SpotLight* pLights)
{
    glUniform1i(m_numSpotLightsLocation, NumLights);

    for (unsigned int i = 0 ; i < NumLights ; i++) {
        glUniform3f(m_spotLightsLocation[i].Color, pLights[i].Color.x, pLights[i].Color.y, pLights[i].Color.z);
        glUniform1f(m_spotLightsLocation[i].AmbientIntensity, pLights[i].AmbientIntensity);
        glUniform1f(m_spotLightsLocation[i].DiffuseIntensity, pLights[i].DiffuseIntensity);
        glUniform3f(m_spotLightsLocation[i].Position, pLights[i].Position.x, pLights[i].Position.y, pLights[i].Position.z);
        Vector3f Direction = pLights[i].Direction;
        Direction.Normalize();
        glUniform3f(m_spotLightsLocation[i].Direction, Direction.x, Direction.y, Direction.z);
        glUniform1f(m_spotLightsLocation[i].Cutoff, cosf(ToRadian(pLights[i].Cutoff)));
        glUniform1f(m_spotLightsLocation[i].Atten.Constant, pLights[i].Attenuation.Constant);
        glUniform1f(m_spotLightsLocation[i].Atten.Linear, pLights[i].Attenuation.Linear);
        glUniform1f(m_spotLightsLocation[i].Atten.Exp, pLights[i].Attenuation.Exp);
    }
}

这个函数根据聚光灯光源的结构体数组来继续更新着色器程序,基本上和点光源中对应的这个函数一样,除了额外又添加了两个参数,光的方向单位化后也传给了shader,另外那个阈值夹角装换成它的余弦值之后也传给了shader(方便shader直接用它和点积运算的结果进行比较)。注意库函数cosf()使用的是弧度值参数,是先用ToRadian宏将角度转换成的弧度值。

iOS中的等同性

问题: 下面的代码会打印什么结果?解释你的答案。

NSString *firstUserName = @"nick";
NSString *secondUserName = @"nick";

if (firstUserName  == secondUserName) {
  NSLog(@"areEqual");
}
else {
  NSLog(@"areNotEqual");
}

程序会输出“areEqual”。

看上去好像答案很明显,其实不然。用‘==’比较两个指针相当于检查他们是否指向同一个对象(指针存储的是对象的地址)。两个指针只有指向同一个对象(两个指针存储相同的对象地址)时这两个指针才相等。两个指针指向的两个对象的值相等,但不是同一个对象,那么两个指针还是不相等,所以这么分析上面按理应该输出“areNotEqual”。

上面的代码片中,firstUserName和secondUserName是指向两个不同对象的指针,虽然对象的内容一样。但是iOS编译器对string对象的引用做了优化,也就是对字符串相同的对象做了重用而不是分别分配空间,所以虽然上面代码是两个指针分别定义的,但实际还是指向了同一个字符串对象,导致两个指针相等了。

OC中等同性扩展:

== 是只比较两个指针地址,那么另外isEqual 和 isEqualToString有什么不同呢?

isEqual是比较两个NSObject的方法,而isEqualToString是比较两个NSString的方法,明显isEqualToString只是专门用来比较字符串的,是isEqual的衍生方法。

isEqual首先是比较两个指针地址,如果地址相同则直接返回YES;如果地址不同再看两个指针指向的对象是否为空以及对象类型是否相同,如果有一个为空或者两者不是同类对象则直接返回NO;如果都不为空且属于同类对象则返回YES。

因此,对于题中两个指针,三种判断方法都会判定两者相等。


问题:下面的代码会打印什么结果?

NSString *str = @"a123";
NSLog(@"%@", (str == @"a123") ? @"yes" : @"no");

这里打印结果为no。这里字符串str和临时的相同内容的字符串比较,‘==’比较的是它们的指针,这样的写法临时的@“a123”是一个新的字符串,虽然字符串内容相同,但是没有被编译器优化,因此和str不是同一个字符串,地址不同。


问题:下面那个方法可以比较两个NSString *str1, *str2的异同:

  • if(str1 = str2) xxx;
  • if([str1 isEqualToString:str2]) xxx;(right)
  • if(str1 && str2) xxx;
  • if([str1 length] == [str2 length]) xxx;

iOS中的数据持久化

iOS中的数据持久化(1): NSUserDefault简单数据快速读写

系统提供的这种简单键值对数据存储在各平台基本都有,添加新数据时要自定义一个key字符串,然后同时添加一个基本类型的数据,支持的数据类型有:NSNumber(Integer、Float、Double等),NSString,NSDate,NSArray,NSDictionary,BOOL。之后需要通过自定义的key来获取存储的数据。获取数据时,如果key值不存在则会返回一个默认值(0或者-1)。

虽然存储的是简单数据,但也可以用来存储集合数据,开发中一般是将集合数据(例如:字典、数组及其组合)转化成JSON字符串,以NSString字符串的形式来保存,这样也便于加密解密。

    /* 获取NSUserDefaults标准函数对象 */
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    /* 存储新对象数据或更新对象数据 */
    [defaults setObject:@"data" forKey:@"key1"];
    /* 通过key值取出对象数据 */
    [defaults objectForKey:@"key1"];
    
    /* 其他类型的数据存取: */
    [defaults setDouble:3.1415926 forKey:@"double1"]; //添加double类型数据
    [defaults doubleForKey:@"double1"];               //获取double类型数据
    
    [defaults setBool:NO forKey:@"bool1"];            //添加BOOL类型数据
    [defaults boolForKey:@"bool1"];                   //获取BOOL类型数据
    
    [defaults setInteger:2016 forKey:@"int1"];        //添加int类型数据
    [defaults integerForKey:@"int1"];                 //获取int类型数据
    
    [defaults setFloat:3.14 forKey:@"float1"];        //添加Float类型数据
    [defaults floatForKey:@"float1"];                 //获取float类型数据
    
    [defaults removeObjectForKey:@"key1"];            //删除对象数据

iOS中的数据持久化(2): 属性列表Property list文件存储

  • Plist文件

Property List,属性列表文件,文件后缀名为.plist因此常叫做plist文件。plist文件主要用来存储串行化后的对象,文件是xml格式的,常用于储存用户设置,也可以用于存储捆绑的信息等。

  • 明确保存位置

在使用plist文件存储前先要明确数据的不同存储位置。

  • 存储到应用沙盒【可读可写】: 沙盒是应用的一个隔离区域,plist文件存储在沙盒的Document文件目录下,类似于Java中的文件读写。写入时要指明plist文件的文件名,例如:“/demo.plist”,其中的‘/’不可忘记添加,因为是要添加路径。如果是新写入的plist文件,则会在Document目录下创建demo.plist这个文件,并写入数据;如果文件已经存在,则会覆盖已经存在的plist文件实现数据更新(等效于我们将同名文件拖入文件夹进行替换)。

  • 存储到应用工程本身【只可读】: 如果是将数据存在工程里,那么plist数据文件是要手工创建的,而非代码添加,代码只可以对手工创建的plist文件进行读取且不可更新,更新只能通过手动修改plist文件更新数据。

  • 读取工程plist文件并写入沙盒以及读取沙盒plist文件

首先手动创建一个简单的plist文件:File —> New—> NewFile,选择Resource下的PropertyList,plist文件(root)可以是一个不可变数组(NSArray),也可以是一个字典(NSDictionary)。一般情况下是一个数组,存储多条数据。这里创建了一个文件名为‘test.plist’的plist文件,Root是一个Dictionary字典,存储几个键值对。

这里写图片描述

下面示例代码实现读取工程中上面手动创建的test.plist文件,对读取的数据进行更新,然后将更新的数据写入沙盒,然后读取沙盒的数据(读取沙盒数据后可以再更新然后再重新写入):

    /* 1.读取工程中的plist文件,这里设置的工程的plist是一个Dictionary字典,也可以用Array数组plist */
    NSMutableDictionary *data = [[NSMutableDictionary alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"test" ofType:@"plist"]];
    NSLog(@"从工程plist读取的数据:%@",data);
    
    /* 添加新数据到字典对象中 */
    [data setObject:@"4444" forKey:@"d"];
    NSLog(@"将要写入沙盒的数据:%@",data);
    
    /* 获取沙盒路径,这里"/demo.plist"是指新建的沙盒里plist文件路径,一定要加“/”!!! */
    NSString *filePath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) lastObject] stringByAppendingPathComponent:@"/demo.plist"];
    /* 2.将更新了的工程中的数据写入沙盒 */
    [data writeToFile:filePath atomically:YES];
    
    /* 3.读取沙盒数据 */ 
    NSMutableDictionary *dic = [[NSMutableDictionary alloc] initWithContentsOfFile:filePath];
    NSLog(@"从沙盒读取的数据:%@",dic);

iOS中的数据持久化(3): Archive归档

归档反归档又叫序列化和反序列化,用法和本地文件存储类似,不过归档反归档经过了复杂对象和NSData数据类型的相互转换,可以存储复杂对象。使用NSKeyedArchiver和NSKeyedUnarchiver类的类方法即可简单进行单一对象的归档和反归档操作。若要实现多个对象的同时归档和反归档则要自定义并初始化一个NSKeyedArchiver对象,将数据封装在NSMutableData中进行统一归档和反归档。

单一对象归档:

/* 沙盒根目录 */
NSString *homeDictory = NSHomeDirectory();
/* 拼接根目录和自定义文件名构建存储路径 */
NSString *homePath = [homeDictory stringByAppendingPathComponent:@"testAchiver"];
/* 待归档的数组,也可归档NSString、NSInteger等基础对象类型 */
NSArray *array = @[@"obj1",@"obj2",@"obj3"];
/* 归档并返回归档是否成功 */
BOOL flag = [NSKeyedArchiver archiveRootObject:array toFile:homePath];
NSArray *unarchivedArray;
/* 如果归档成功则解归档 */
if (flag) {
    unarchivedArray = [NSKeyedUnarchiver unarchiveObjectWithFile:homePath];
}
NSLog(@"%@",unarchivedArray);
2017-04-08 12:07:28.180332 CommandLine[65032:4610773] (
    obj1,
    obj2,
    obj3
)

多对象归档:

/* 归档路径 */
NSString *homeDictory = NSHomeDirectory();
NSString *homePath = [homeDictory stringByAppendingPathComponent:@"testAchiver"];

/* 待归档的数据 */
NSInteger intVal = 1;
NSString *strVal = @"string";
CGPoint pointVal = CGPointMake(1.0, 1.0);
/* 初始化归档工具 */
NSMutableData *mulData = [[NSMutableData alloc] init];
NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc]initForWritingWithMutableData:mulData];
/* 归档 */
[archiver encodeInteger:intVal forKey:@"key_integer"];
[archiver encodeObject:strVal forKey:@"key_string"];
[archiver encodePoint:pointVal forKey:@"key_point"];
[archiver finishEncoding];

/* 归档的数据写入 */
[mulData writeToFile:homePath atomically:YES];

/* 解归档 */
NSMutableData *undata = [[NSMutableData alloc]initWithContentsOfFile:homePath];
NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc]initForReadingWithData:undata];
CGPoint unpointVal = [unarchiver decodePointForKey:@"key_point"];
NSString *unstrVal = [unarchiver decodeObjectForKey:@"key_string"];
NSInteger unintegerVal = [unarchiver decodeIntegerForKey:@"key_integer"];
[unarchiver finishDecoding];
NSLog(@"%f,%f,%@,%ld",unpointVal.x,unpointVal.y,unstrVal,(long)unintegerVal);
2017-04-08 12:04:26.738925 CommandLine[64998:4609662] 1.000000,1.000000,string,1

iOS中的数据持久化(4): Sqlite本地数据库

iOS中的Sqlite是一个使用ANSI-C开发的自包含的关系型数据库,可以用于存储大规模的数据。Sqlite的操作管理很简单,有小巧、快速、可靠的特点。另外Sqlite使用互斥来保证多线程环境下数据操作的安全性。

由于Sqlite源码就是一个叫做sqlite3.c的文件,在工程中引入使用时只要加入这个库文件同时在代码中引入sqlite.h头文件即可。


iOS中的数据持久化(5): Core Data

Core Data是iOS5之后苹果官方提供的一种应用数据管理框架,可以用图形界面的方式快速的定义app的数据模型,同时在代码中很容易获取这些数据模型。Core data提供了基础结构用于处理常用的功能,例如保存,恢复,撤销和重做,允许在app中继续创建新的任务。在使用Core Data的时候,不需要安装额外的数据库系统,因为Core Data使用内置的sqlite数据库。Core Data将app的模型层放入到一组定义在内存中的数据对象中,Core Data会追踪这些对象的改变,同时可以根据需要做相反的改变,例如用户执行撤销命令。当Core Data在对app数据的改变进行保存的时候,Core Data会把这些数据归档,并永久性保存。

Core Data的最本质特点是提供了一种将对象模型关系数据进行映射的功能,称之为对象-关系映射(ORM),可以将模型对象转化成关系数据以保存到Sqlite数据库中,也可以将保存在Sqlite数据库中的关系型数据转换成模型对象。


问题:iOS平台怎么做数据的持久化? Coredata和Sqlite有无必然联系?Coredata是一个关系型数据库吗?

iOS中可以有四种持久化数据的方式:属性列表、对象归档、SQLite3和Core Data;

Sqlite是一个轻量级功能强大的关系数据引擎,可以很容易嵌入到应用程序,可以在多个平台使用。Sqlite是一个轻量级的嵌入式sql数据库编程。与Core Data框架不同的是,sqlite是使用程序式的,sql的主要的API来直接操作数据表。

Core Data不是一个关系型数据库,也不是关系型数据库管理系统(RDBMS)。虽然Core Data支持SQLite作为一种存储类型,但它不能使用任意的SQLite数据库。Core Data在使用的过程中自己创建这个数据库。Core Data支持对一、对多的关系。


问题: 什么是NSManagedObject模型?

NSManagedObject是NSObject的子类,也是CoreData的重要组成部分,它是一个通用的类,实现了CoreData模型层所需的基本功能,用户可通过子类化NSManagedObject,建立自己的数据模型。


问题: 什么是NSManagedobjectContext? 描述一下管理对象上下文和它提供的方法。

NSManagedobjectContext对象负责应用和数据库之间的交互。


问题: 什么是序列化或者Acrchiving,可以用来做什么,怎样与copy结合,原理是什么?


iOS多线程编程

OC中的多线程

OC中多线程根据封装程度可以分为三个层次:NSThreadGCDNSOperation,另外由于OC兼容C语言,因此仍然可以使用C语言的POSIX接口来实现多线程,只需引入相应的头文件:#include <pthread.h>

NSThread

NSThread是封装程度最小最轻量级的,使用更灵活,但要手动管理线程的生命周期、线程同步和线程加锁等,开销较大;

NSThread的基本使用比较简单,可以动态创建初始化NSThread对象,对其进行设置然后启动;也可以通过NSThread的静态方法快速创建并启动新线程;此外NSObject基类对象还提供了隐式快速创建NSThread线程的performSelector系列类别扩展工具方法;NSThread还提供了一些静态工具接口来控制当前线程以及获取当前线程的一些信息。

下面以在一个UIViewController中为例展示NSThread的使用方法:

- (void)viewDidLoad {
    [super viewDidLoad];

    /** NSThread静态工具方法 **/
    /* 1 是否开启了多线程 */
    BOOL isMultiThreaded = [NSThread isMultiThreaded];
    /* 2 获取当前线程 */
    NSThread *currentThread = [NSThread currentThread];
    /* 3 获取主线程 */
    NSThread *mainThread = [NSThread mainThread];
    NSLog(@"main thread");
    /* 4 睡眠当前线程 */
    /* 4.1 线程睡眠5s钟 */
    [NSThread sleepForTimeInterval:5];
    /* 4.2 线程睡眠到指定时间,效果同上 */
    [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:5]];
    /* 5 退出当前线程,注意不要在主线程调用,防止主线程被kill掉 */
    //[NSThread exit];
    NSLog(@"main thread");
    
    /** NSThread线程对象基本创建,target为入口函数所在的对象,selector为线程入口函数 **/
    /* 1 线程实例对象创建与设置 */
    NSThread *newThread= [[NSThread alloc] initWithTarget:self selector:@selector(run) object:nil];
    /* 设置线程优先级threadPriority(0~1.0),即将被抛弃,将使用qualityOfService代替 */
    newThread.threadPriority = 1.0;
    newThread.qualityOfService = NSQualityOfServiceUserInteractive;
    /* 开启线程 */
    [newThread start];
    /* 2 静态方法快速创建并开启新线程 */
    [NSThread detachNewThreadSelector:@selector(run) toTarget:self withObject:nil];
    [NSThread detachNewThreadWithBlock:^{
        NSLog(@"block run...");
    }];
    
    /** NSObejct基类隐式创建线程的一些静态工具方法 **/
    /* 1 在当前线程上执行方法,延迟2s */
    [self performSelector:@selector(run) withObject:nil afterDelay:2.0];
    /* 2 在指定线程上执行方法,不等待当前线程 */
    [self performSelector:@selector(run) onThread:newThread withObject:nil waitUntilDone:NO];
    /* 3 后台异步执行函数 */
    [self performSelectorInBackground:@selector(run) withObject:nil];
    /* 4 在主线程上执行函数 */
    [self performSelectorOnMainThread:@selector(run) withObject:nil waitUntilDone:NO];
}

- (void)run {
    NSLog(@"run...");
}

GCD大中央调度

GCD(Grand Central Dispatch),又叫大中央调度,对线程操作进行了封装,加入了很多新的特性,内部进行了效率优化,提供了简洁的C语言接口,使用更加简单高效,也是苹果推荐的方式。

对于GCD多线程编程的理解需要结合实例和实践去体会、总结,网上有一篇国外的GCD详细教程,结合案例通俗易懂,可以帮助快速掌握,文章地址为:https://www.raywenderlich.com/60749/grand-central-dispatch-in-depth-part-1,另外国内有业界人士对其做了翻译,中文版地址为:https://github.com/nixzhu/dev-blog/blob/master/2014-04-19-grand-central-dispatch-in-depth-part-1.md,教程分两部分,此处给出第一部分地址,第二部分可因此找到。这里对其进行进一步的总结提炼,整理出如下必会内容,帮助快速掌握使用:

  • 同步dispatch_sync与异步dispatch_async任务派发
  • 串行队列与并发队列dispatch_queue_t
  • dispatch_once_t只执行一次
  • dispatch_after延后执行
  • dispatch_group_t组调度

两个关键概念

串行与并发(Serial和Concurrent):

这个概念在创建操作队列的时候有宏定义参数,用来指定创建的是串行队列还是并行队列。

串行指的是队列内任务一个接一个的执行,任务之间要依次等待不可重合,且添加的任务按照先进先出FIFO的顺序执行,但并不是指这就是单线程,只是同一个串行队列内的任务需要依次等待排队执行避免出现竞态条件,但仍然可以创建多个串行队列并行的执行任务,也就是说,串行队列内是串行的,串行队列之间仍然是可以并行的,同一个串行队列内的任务的执行顺序是确定的(FIFO),且可以创建任意多个串行队列;

并行指的是同一个队列先后添加的多个任务可以同时并列执行,任务之间不会相互等待,且这些任务的执行顺序执行过程不可预测。

同步和异步任务派发(Synchronous和Asynchronous):GCD多线程编程时经常会使用dispatch_async和dispatch_sync函数往指定队列中添加任务块,区别就是同步和异步。同步指的是阻塞当前线程,要等添加的耗时任务块block完成后,函数才能返回,后面的代码才可以继续执行。如果在主线上,则会发生阻塞,用户会感觉应用不响应,这是要避免的。而有时需要使用同步任务的原因是想保证先后添加的任务要按照编写的逻辑顺序依次执行;异步指的是将任务添加到队列后函数立刻返回,后面的代码不用等待添加的任务完成返回即可继续执行。

dispatch_syncdispatch_async

通过下面的代码比较异步和同步任务的区别:

/* 1. 提交异步任务 */
NSLog(@"开始提交异步任务:");
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /* 耗时任务... */
    [NSThread sleepForTimeInterval:10];
});
NSLog(@"异步任务提交成功!");
    
/* 2. 提交同步任务 */
NSLog(@"开始提交同步任务:");
dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    /* 耗时任务... */
    [NSThread sleepForTimeInterval:10];
});
NSLog(@"同步任务提交成功!");

打印结果:

2017-02-28 16:01:44.643 SingleView[19100:708069] 开始提交异步任务:
2017-02-28 16:01:44.643 SingleView[19100:708069] 异步任务提交成功!
2017-02-28 16:01:44.644 SingleView[19100:708069] 开始提交同步任务:
2017-02-28 16:01:54.715 SingleView[19100:708069] 同步任务提交成功!

通过打印结果的时间可以看出,异步任务提交后立即就执行下一步打印提交成功了,不会阻碍当前线程,提交的任务会在后台去执行;而提交同步任务要等到提交的后台任务结束后才可以继续执行当前线程的下一步。此处在主线程上添加的同步任务就会阻塞主线程,导致后面界面的显示要延迟,影响用户体验。

dispatch_queue_t 操作队列主要有两种,并发队列和串行队列,它们的区别上面已经提到,具体创建的方法很简单,要提供两个参数,一个是标记该自定义队列的唯一字符串,另一个是指定串行队列还是并发队列的宏参数:

/* 创建一个并发队列 */
dispatch_queue_t concurrent_queue = dispatch_queue_create("demo.gcd.concurrent_queue", DISPATCH_QUEUE_CONCURRENT);
/* 创建一个串行队列 */
dispatch_queue_t serial_queue = dispatch_queue_create("demo.gcd.serial_queue", DISPATCH_QUEUE_SERIAL);

另外GCD还提供了几个常用的全局队列以及主队列,获取方法如下:

// 获取主队列(在主线程上执行)
dispatch_queue_t main_queue = dispatch_get_main_queue();
// 获取不同优先级的全局队列(优先级从高到低)
dispatch_queue_t queue_high = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_queue_t queue_default = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_queue_t queue_low = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0);
dispatch_queue_t queue_background = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);

dispatch_once_t 这个函数控制指定代码只会被执行一次,常用来实现单例模式,这里以单例模式实现的模板代码为例展示dispatch_once_t的用法,其中的实例化语句只会被执行一次:

+ (instancetype *)sharedInstance {
    static dispatch_once_t once = 0;
    static id sharedInstance = nil;
    dispatch_once(&once, ^{
        // 只实例化一次
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

dispatch_after 通过该函数可以让要提交的任务在从提交开始后的指定时间后执行,也就是定时延迟执行提交的任务,使用方法很简单:

    // 定义延迟时间:3s
    dispatch_time_t delay = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC));
    dispatch_after(delay, dispatch_get_main_queue(), ^{
        // 要执行的任务...
    });

dispatch_group_t

组调度可以实现等待一组操作都完成后执行后续操作,典型的例子是大图片的下载,例如可以将大图片分成几块同时下载,等各部分都下载完后再后续将图片拼接起来,提高下载的效率。使用方法如下:

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, queue, ^{ /*操作1 */ });
dispatch_group_async(group, queue, ^{ /*操作2 */ });
dispatch_group_async(group, queue, ^{ /*操作3 */ }); 
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 后续操作...
});

同步代码到主线程

对于UI的更新代码,必须要在主线程上执行才会及时有效,当当前代码不在主线程时,需要将UI更新的部分代码单独同步到主线程,同步的方法有三种,可以使用NSThread类的performSelectorOnMainThread方法或者NSOperationQueue类的mainQueue主队列来进行同步,但推荐直接使用GCD方法:

dispatch_async(dispatch_get_main_queue(), ^{
        // UI更新代码...
    });

NSOperation

NSOperation是基于GCD的一个抽象基类,将线程封装成要执行的操作,不需要管理线程的生命周期和同步,但比GCD可控性更强,例如可以加入操作依赖(addDependency)、设置操作队列最大可并发执行的操作个数(setMaxConcurrentOperationCount)、取消操作(cancel)等。NSOperation作为抽象基类不具备封装我们的操作的功能,需要使用两个它的实体子类:NSBlockOperation和NSInvocationOperation,或者继承NSOperation自定义子类。

NSBlockOperation和NSInvocationOperation用法的主要区别是:前者执行指定的方法,后者执行代码块,相对来说后者更加灵活易用。NSOperation操作配置完成后便可调用start函数在当前线程执行,如果要异步执行避免阻塞当前线程则可以加入NSOperationQueue中异步执行。他们的简单用法如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    /* NSInvocationOperation初始化 */
    NSInvocationOperation *invoOpertion = [[NSInvocationOperation alloc] initWithTarget:self selector:@selector(run) object:nil];
    [invoOpertion start];
    
    /* NSBlockOperation初始化 */
    NSBlockOperation *blkOperation = [NSBlockOperation blockOperationWithBlock:^{
        NSLog(@"NSBlockOperation");
    }];
    [blkOperation start];
}

- (void)run {
    NSLog(@"NSInvocationOperation");
}

另外NSBlockOperation可以后续继续添加block执行块,操作启动后会在不同线程并发的执行这些执行快:

/* NSBlockOperation初始化 */
NSBlockOperation *blkOperation = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"NSBlockOperationA");
}];
[blkOperation addExecutionBlock:^{
    NSLog(@"NSBlockOperationB");
}];
[blkOperation addExecutionBlock:^{
    NSLog(@"NSBlockOperationC");
}];
[blkOperation start];
2017-04-04 11:27:02.805 SingleView[12008:3666657] NSBlockOperationB
2017-04-04 11:27:02.805 SingleView[12008:3666742] NSBlockOperationC
2017-04-04 11:27:02.805 SingleView[12008:3666745] NSBlockOperationA

另外说了NSOperation的可控性比GCD要强,其中一个非常重要的特性是可以设置各操作之间的依赖,即强行规定操作A要在操作B完成之后才能开始执行,成为操作A依赖于操作B:

/* 获取主队列(主线程) */
NSOperationQueue *queue = [NSOperationQueue mainQueue];
/* 创建a、b、c操作 */
NSOperation *c = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"OperationC");
}];
NSOperation *a = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"OperationA");
}];
NSOperation *b = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"OperationB");
}];
/* 添加操作依赖,c依赖于a和b,这样c一定会在a和b完成后才执行,即顺序为:A、B、C */
[c addDependency:a];
[c addDependency:b];
/* 添加操作a、b、c到操作队列queue(特意将c在a和b之前添加) */
[queue addOperation:c];
[queue addOperation:a];
[queue addOperation:b];

NSBlockOperation和NSInvocationOperation可以满足多数情况下的编程需求,如果需求特殊则需要继承NSOperation类自定义子类来更加灵活的实现。 ***

问题:什么是线程?它与进程有什么区别?为什么要使用多线程?

线程是指程序在执行过程中,能够执行程序代码的一个执行单元。线程主要有四种状态:运行、就绪、挂起、结束。

进程是指一段正在执行的程序。而线程有时候也被称为轻量级进程,是程序执行的最小单元,一个进程可以拥有多个线程,各个线程之间共享程序的内存空间(代码段、数据段和堆空间)及一些进程级的资源(例如打开的文件),但是各个线程拥有自己的栈空间,进程与线程的关系如下图所示:

这里写图片描述

在操作系统级别上,程序的执行都是以进程为单位的,而每个进程中通常都会有多个线程互不影响地并发执行,那么为什么要使用多线程呢?其实,多线程的使用为程序研发带来了巨大的便利,具体而言,有以下几个方面的内容:

  1. 使用多线程可以减少程序的响应时间。在单线程(单线程指的是程序执行过程中只有一个有效操作的序列,不同操作之间都有明确的执行先后顺序)的情况下,如果某个操作很耗时,或者陷入长时间的等待(如等待网络响应),此时程序将不会响应鼠标和键盘等操作,使用多线程后,可以把这个耗时的线程分配到一个单独的线程去执行,使得程序具备了更好的交互性。
  2. 与进程相比,线程的创建和切换开销更小。由于启动一个新的线程必须给这个线程分配独立的地址空间,建立许多数据结构来维护线程代码段、数据段等信息,而运行于同一进程内的线程共享代码段、数据段,线程的启动或切换的开销比进程要少很多。同时多线程在数据共享方面效率非常高。
  3. 多CPU或多核计算机本身就具有执行多线程的能力,如果使用单个线程,将无法重复利用计算机资源,造成资源的巨大浪费。因此在多CPU计算机上使用多线程能提高CPU的利用率。
  4. 使用多线程能简化程序的结构,使程序便于理解和维护。一个非常复杂的进程可以分成多个线程来执行。

问题: 列举Cocoa中常见的几种多线程的实现,并谈谈多线程安全的几种解决办法,一般什么地方会用到多线程?

问的是三个层次的多线程编程实现;线程锁的使用;

  • 只在主线程刷新访问UI
  • 如果要防止资源抢夺,得用synchronized进行加锁保护
  • 如果异步操作要保证线程安全等问题, 尽量使用GCD(有些函数默认 就是安全的)

问题: OC中创建线程的方法是什么?如果在主线程中执行代码,方法是什么?如果想延时执行代码、方法又是什么? ***

问题: 在iphone上有两件事情要做,请问是在一个线程里按顺序做效率高还是两个线程里做效率高?为什么?

这里的效率高指的是时间上效率高,也就是希望在最短的时间内完成所有任务,两件事情按顺序做意味着串行执行,第二件事情要等待第一件事情结束后才可开始,效率相对很低。但如果利用两个线程让两件事情能够并发执行,则时间上效率会大大提高。 ***

问题: runloop是什么?在主线程中的某个函数里调用了异步函数,怎样block当前线程,且还能响应当前线程的timer事件,touch事件等? ***

问题: 对比在OS X和iOS中实现并发性的不同方法。

在iOS中有三种方法可以实现并发性:threads、dispatch queues和operation queues。

threads的缺点是开发者要自己创建合适的并发解决方案,要决定创建多少线程并且要根据情况动态调整线程的数量。并且应用还要为创建和维护线程承担主要代价, 因此OS X和IOS系统并不是依靠线程来解决并发问题的,而是采用了异步设计的途径。

其中一个开启异步任务的技术是GCD(Grand Central Dispatch),让系统层次来对线程进行管理。开发者要做的就是定义要执行的任务并将之添加到合适的dispatch分派队列。GCD会负责创建需要的线程并安排任务在这些线程上运行。所有的dispatch队列都是先进先出(FIFO)队列结构,所以任务的执行顺序是和添加顺序是一致的。

operation queues和并发dispatch队列一样都是通过Cocoa框架的NSOperationQueue类实现的,不过它并不一定是按照先进先出的顺序执行任务的,而是支持创建更复杂的执行顺序图来管理任务的执行顺序。 ***

问题: 操作队列(NSOperation queue)是什么?解释NSOperationQueue串行操作队列和并行操作队列的区别。 ***

问题: 用户下载一个图片,图片很大,需要分成很多份进行下载,使用GCD应该如何实现?使用什么队列?

使用Dispatch Group追加block到Global Group Queue,这些block如果全部执行完毕,就会执行Main Dispatch Queue中的结束处理的block。

dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_t group = dispatch_group_create();
dispatch_group_async(group, queue, ^{ /*加载图片1 */ });
dispatch_group_async(group, queue, ^{ /*加载图片2 */ });
dispatch_group_async(group, queue, ^{ /*加载图片3 */ }); 
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
        // 合并图片
});

问题:Dispatch_barrier_(a)sync的作用?

通过Dispatch_barrier_async添加的操作会暂时阻塞当前队列,即等待前面的并发操作都完成后执行该阻塞操作,待其完成后后面的并发操作才可继续。可以将其比喻为一座霸道的独木桥,是并发队列中的一个并发障碍点,临时阻塞并独占。

可见使用Dispatch_barrier_async可以实现类似dispatch_group_t组调度的效果,同时主要的作用是避免数据竞争,高效访问数据。

/* 创建并发队列 */
dispatch_queue_t concurrentQueue = dispatch_queue_create("test.concurrent.queue", DISPATCH_QUEUE_CONCURRENT);
/* 添加两个并发操作A和B,即A和B会并发执行 */
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationA");
});
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationB");
});
/* 添加barrier障碍操作,会等待前面的并发操作结束,并暂时阻塞后面的并发操作直到其完成 */
dispatch_barrier_async(concurrentQueue, ^(){
    NSLog(@"OperationBarrier!");
});
/* 继续添加并发操作C和D,要等待barrier障碍操作结束才能开始 */
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationC");
});
dispatch_async(concurrentQueue, ^(){
    NSLog(@"OperationD");
});
2017-04-04 12:25:02.344 SingleView[12818:3694480] OperationB
2017-04-04 12:25:02.344 SingleView[12818:3694482] OperationA
2017-04-04 12:25:02.345 SingleView[12818:3694482] OperationBarrier!
2017-04-04 12:25:02.345 SingleView[12818:3694482] OperationD
2017-04-04 12:25:02.345 SingleView[12818:3694480] OperationC

问题: 用过NSOperationQueue吗?如果用过或者了解的话,为什么要使用 NSOperationQueue?实现了什么?简述它和GCD的区别和类似的地方(提示:可以从两者的实现机制和适用范围来述)。

使用NSOperationQueue用来管理子类化的NSOperation对象,控制其线程并发数目。GCD和NSOperation都可以实现对线程的管理,区别是NSOperation和NSOperationQueue是多线程的面向对象抽象。项目中使用NSOperation的优点是 NSOperation是对线程的高度抽象,在项目中使用它,会使项目的程序结构更好,子类化 NSOperation的设计思路,是具有面向对象的优点(复用、封装),使得实现是多线程支持,而接口简单,建议在复杂项目中使用。项目中使用GCD的优点是GCD本身非常简单、易用,对于不复杂的多线程操作,会节省代码量,而Block参数的使用,会使代码更为易读,建议在简单项目中使用。

  • GCD是纯C语言的API,NSOperationQueue是基于GCD的OC版本封装

  • GCD只支持FIFO的队列,NSOperationQueue可以很方便地调整执行顺 序、设置最大并发数量

  • NSOperationQueue可以在轻松在Operation间设置依赖关系,而GCD 需要写很多的代码才能实现

  • NSOperationQueue支持KVO,可以监测operation是否正在执行 (isExecuted)、是否结束(isFinished),是否取消(isCanceld) 5> GCD的执行速度比NSOperationQueue快 任务之间不太互相依赖:GCD 任务之间有依赖\或者要监听任务的执行情况:NSOperationQueue


问题: 在使用GCD以及block时要注意些什么?它们两是一回事儿么?block在ARC中和传统的MRC中的行为和用法有没有什么区别,需要注意些什么?

使用block是要注意,若将block做函数参数时,需要把它放到最后,GCD是Grand Central Dispatch,是一个对线程开源类库,而Block是闭包,是能够读取其他函数内部变量的函数。


问题: 在项目什么时候选择使用GCD,什么时候选择 NSOperation?

项目中使用NSOperation的优点是NSOperation是对线程的高度抽象,在项目中使用它,会使项目的程序结构更好,子类化NSOperation的设计思路,是具有面向对象的优点(复用、封装),使得实现多线程支持,而接口简单,建议在复杂项目中使用。

项目中使用GCD的优点是GCD本身非常简单、易用,对于不复杂的多线程操作,会节省代码量,而Block参数的使用,会是代码更为易读,建议在简单项目中使用。


问题:对于a,b,c三个线程,如何使用用NSOpertion和NSOpertionQueue实现执行完a,b后再执行c?

可以通过NSOpertion的依赖特性实现该需求,即让c依赖于a和b,这样只有a和b都执行完后,c才可以开始执行:

/* 获取主队列(主线程) */
NSOperationQueue *queue = [NSOperationQueue mainQueue];
/* 创建a、b、c操作 */
NSOperation *c = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"Operation C Start!");
    // ... ...
}];
NSOperation *a = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"Operation A Start!");
    [NSThread sleepForTimeInterval:3.0];
    NSLog(@"Operation A Done!");
}];
NSOperation *b = [NSBlockOperation blockOperationWithBlock:^{
    NSLog(@"Operation B Start!");
    [NSThread sleepForTimeInterval:3.0];
    NSLog(@"Operation B Done!");
}];
/* 添加操作依赖,c依赖于a和b */
[c addDependency:a];
[c addDependency:b];
/* 添加操作a、b、c到操作队列queue(特意将c在a和b之前添加) */
[queue addOperation:c];
[queue addOperation:a];
[queue addOperation:b];

打印结果:

2017-03-18 13:51:37.770 SingleView[15073:531745] Operation A Start!
2017-03-18 13:51:40.772 SingleView[15073:531745] Operation A Done!
2017-03-18 13:51:40.775 SingleView[15073:531745] Operation B Start!
2017-03-18 13:51:43.799 SingleView[15073:531745] Operation B Done!
2017-03-18 13:51:43.800 SingleView[15073:531745] Operation C Start!

问题:GCD内部是怎么实现的?

  • iOS和OS X的核心是XNU内核,GCD是基于XNU内核实现的

  • GCD的API全部在libdispatch库中

  • GCD的底层实现主要有Dispatch Queue和Dispatch Source

  • Dispatch Queue :管理block(操作)

  • Dispatch Source :处理事件


问题:既然提到GCD,那么问一下在使用GCD以及 block 时要注意些什么?它们两是一回事儿么? block 在 ARC 中和传统的 MRC 中的行为和用法有 没有什么区别,需要注意些什么?

Block的使用注意:

  1. block的内存管理
    • 防止循环retian
    • 非ARC(MRC):__block
    • ARC:__weak__unsafe_unretained

问题:下面代码有什么问题?

int main(int argc, const char * argv[]) {
    NSLog(@"1");
    dispatch_sync(dispatch_get_main_queue(), ^{
        NSLog(@"2");
    });
    NSLog(@"3");
    return 0;
}

main函数中第二句代码在主线程上使用了dispatch_sync同步向主线程派发任务,而同步派发要等到任务完成后才能返回,阻塞当前线程。也就是说执行到此处,主线程被阻塞,同时又要等主线程执行完成该任务,造成主线程自身的等待循环,也就是死锁。程序运行到此处会崩溃。将dispatch_sync改为dispatch_async异步派发任务即可避免死锁,或者将任务派发到其他队列上而不是主队列。


问题: 简单介绍下NSURLConnection类,以及+sendSynchronousRequest:returningResponse:error:与– initWithRequest:delegate:两个方法的区别?

NSURLConnection主要用于网络链接请求,提供了异步和同步两种请求方式,异步请求会新创建一个线程单独用于之后的下载,不会阻塞当前调用的线程;同步请求会阻塞当前调用线程,等待它下载结束,如果在主线程上进行同步请求则会阻塞主线程,造成界面卡顿。

+sendSynchronousRequest:returningResponse:error:是同步请求数据,会阻塞当前的线程,直到request返回response;

–initWithRequest:delegate:是异步请求数据,不会足阻塞当前线程,当数据请求结束后会通过代理回到主线程,并通知它委托的对象。


问题: UIKit类要在哪一个应用线程上使用?

UIKit的界面类只能在主线程上使用,对界面进行更新,多线程环境中要对界面进行更新必须要切换到主线程上。 ***

问题: 以下代码有什么问题?如何修复?

@interface TTWaitController : UIViewController

@property (strong, nonatomic) UILabel *alert;

@end

@implementation TTWaitController

- (void)viewDidLoad
{
    CGRect frame = CGRectMake(20, 200, 200, 20);
    self.alert = [[UILabel alloc] initWithFrame:frame];
    self.alert.text = @"Please wait 10 seconds...";
    self.alert.textColor = [UIColor whiteColor];
    [self.view addSubview:self.alert];

    NSOperationQueue *waitQueue = [[NSOperationQueue alloc] init];
    [waitQueue addOperationWithBlock:^{
        [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
        self.alert.text = @"Thanks!";
    }];
}

@end

@implementation TTAppDelegate

- (BOOL)application:(UIApplication *)application
  didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    self.window.rootViewController = [[TTWaitController alloc] init];
    [self.window makeKeyAndVisible];
    return YES;
}

这段代码是想提醒用户等待10s,10s后在标签上显示“Thanks”,但多线程代码部分NSOperationQueue的addOperationWithBlock函数不能保证block里面的语句是在主线程中运行的,UILabel显示文字属于UI更新,必须要在主线程进行,否则会有未知的操作,无法在界面上及时正常显示。

解决方法是将UI更新的代码写在主线程上即可,代码同步到主线程上主要有三种方法:NSThread、NSOperationQueue和GCD,三个层次的多线程都可以获取主线程并同步。

NSThread级主线程同步:

NSOperationQueue *waitQueue = [[NSOperationQueue alloc] init];
[waitQueue addOperationWithBlock:^{
    [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
    // 同步到主线程
    [self performSelectorOnMainThread:@selector(updateUI) withObject:nil waitUntilDone:NO];
}];

/**
 * UI更新函数
 */
- (void)updateUI {
    self.alert.text = @"Thanks!";
}

NSOperationQueue级主线程同步:

NSOperationQueue *waitQueue = [[NSOperationQueue alloc] init];
[waitQueue addOperationWithBlock:^{
    [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:10]];
    // 同步到主线程
    [[NSOperationQueue mainQueue] addOperationWithBlock:^{
        self.alert.text = @"Thanks!";
    }];
}];

GCD级主线程同步:

NSOperationQueue *waitQueue = [[NSOperationQueue alloc] init];
[waitQueue addOperationWithBlock:^{
    [NSThread sleepUntilDate:[NSDate dateWithTimeIntervalSinceNow:3]];
    // 同步到主线程
    dispatch_async(dispatch_get_main_queue(), ^{
        self.alert.text = @"Thanks!";
    });
}];

iOS中的GC垃圾回收机制与内存管理机制

问题: NSArray和NSMutableArray在Copy和MutableCopy下的内存是怎样的?深拷贝和浅拷贝的区别? 有两种情况:浅拷贝和深拷贝。

浅拷贝只是复制了内存地址,也就是对内存空间的引用;

深拷贝是开辟新的空间并复制原空间相同的内容,新指针指向新空间内容。

除了NSArray在Copy下是浅复制,其他都是深复制。

    // 不可变数组
    NSArray *oldArray = @[@1, @2, @3];
    NSArray *newArray;
    // 可变数组
    NSMutableArray *oldMulArray = [[NSMutableArray alloc]initWithArray:oldArray];
    NSMutableArray *newMulArray;
    
    newArray = [oldArray copy];               // 浅拷贝
    newArray = [oldArray mutableCopy];        // 深拷贝
    newMulArray = [oldMulArray copy];         // 深拷贝
    newMulArray = [oldMulArray mutableCopy];  // 深拷贝

问题: MRC中通过调用静态方法创建的新对象,不再使用时需要对其发送release消息吗?

不需要,因为约定静态方法创建的对象会自动将其放入自动释放池,即已对其发送autorelease消息,因此不可再对其进行手动释放。MRC中静态方法创建新对象的实现模板如下:

/**
 * 静态构造函数
 */
+ (instancetype)personWithName:(NSString *)name age:(NSInteger)age;

+ (instancetype)personWithName:(NSString *)name age:(NSInteger)age {
    Person *person = [[[Person alloc] init] autorelease];
    person.name = name;
    person.age = age;
    return person;
}

问题: BAD_ACCESS在什么情况下出现?

BAD_ACCESS报错属于内存错误,会导致程序崩溃,错误的原因是访问了野指针(悬挂指针)。野指针指的是本来指针指向的对象已经释放了,但指向该对象的指针没有置nil,程序还以为该指针指向那个对象,导致存在一些潜在的危险访问操作,这些危险访问操作就会导致BAD_ACCESS错误造成程序崩溃。访问的含义包括多种情况,例如:向野指针发送消息,读写野指针本来指向的对象的成员变量等等。 ***

问题: 在block内如何修改block外部变量?

在block内部修改block外部变量会编译不通过,提示变量缺少__block修饰,不可赋值。要想在block内部修改block外部变量,则必须在外部定义变量时,前面加上__block修饰符:

/* block外部变量 */
__block int var1 = 0;
int var2 = 0;
/* 定义block */
void (^block)(void) = ^{
    /* 试图修改block外部变量 */
    var1 = 100;
    /* 编译错误,在block内部不可对var2赋值 */
    // var2 = 1;
};
/* 执行block */
block();
NSLog(@"修改后的var1:%d",var1); // 修改后的var1:100

问题: 使用block时什么情况会发生引用循环,如何解决?

常见的使用block引起引用循环的情况为:在一个对象中强引用了一个block,在该block中又强引用了该对象,此时就出现了该对象和该block的循环引用,例如:

/* Test.h */
#import <Foundation/Foundation.h>
/* 声明一个名为MYBlock的block,参数为空,返回值为void */
typedef void (^MYBlock)();

@interface Test : NSObject
/* 定义并强引用一个MYBlock */
@property (nonatomic, strong) MYBlock block;
/* 对象属性 */
@property (nonatomic, copy) NSString *name;

- (void)print;

@end

/* Test.m */
#import "Test.h"
@implementation Test

- (void)print {
    self.block = ^{
        NSLog(@"%@",self.name);
    };
    self.block();
}

@end

解决上面的引用循环的方法一个是强制将一方置nil,破坏引用循环,另外一种方法是将对象使用__weak或者__block修饰符修饰之后再在block中使用(注意是在ARC下):

- (void)print {
    __weak typeof(self) weakSelf = self;
    self.block = ^{
        NSLog(@"%@",weakSelf.name);
    };
    self.block();
}

问题: 下面代码有什么问题?如何修改?

#import "TTAppDelegate.h"

@interface TTParent : NSObject

@property (atomic) NSMutableArray *children;

@end

@implementation TTParent
@end

@interface TTChild : NSObject

@property (atomic) TTParent *parent;

@end

@implementation TTChild
@end

@implementation TTAppDelegate

- (BOOL)application:(UIApplication *)application
  didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    TTParent *parent = [[TTParent alloc] init];
    parent.children = [[NSMutableArray alloc] init];
    for (int i = 0; i < 10; i++) {
        TTChild *child = [[TTChild alloc] init];
        child.parent = parent;
        [parent.children addObject:child];
    }
    return YES;
}
@end

这是一个很明显的“循环引用”问题,parent持有children数组,数组持有每一个child,每个child又持有parent,这样即使外部的所有引用都释放了,由于这个引用循环,parent的引用计数将永远大于0永远无法被释放。

解决方法很简单,将child对parent的引用改为weak弱引用即可,这样child对parent的弱引用不会导致parent的引用计数增加,且parent释放后,weak修饰的对parent引用的指针会自动置nil。


问题:僵尸对象、野指针、空指针分别指什么,有什么区别?

僵尸对象:一个OC对象引用计数为0被释放后就变成僵尸对象了,僵尸对象的内存已经被系统回收,虽然可能该对象还存在,数据依然在内存中,但僵尸对象已经是不稳定对象了,不可以再访问或者使用,它的内存是随时可能被别的对象申请而占用的;

野指针:野指针出现的原因是指针没有赋值,或者指针指向的对象已经被释放掉了,野指针指向一块随机的垃圾内存,向他们发送消息会报EXC_BAD_ACCESS错误导致程序崩溃;

空指针:空指针不同于野指针,它是一个没有指向任何东西的指针,空指针是有效指针,值为nil、NULL、Nil或0等,给空指针发送消息不会报错,只是不响应消息而已,应该给野指针及时赋予零值变成有效的空指针,避免内存报错。 ***

问题: Objective-C有GC垃圾回收机制吗?

GC(Garbage Collection),垃圾回收机制,简单地说就是程序中及时处理废弃不用的内存对象的机制,防止内存中废弃对象堆积过多造成内存泄漏。Objective-C语言本身是支持垃圾回收机制的,但有平台局限性,仅限于Mac桌面系统开发中,而在iPhone和iPad等苹果移动终端设备中是不支持垃圾回收机制的。在移动设备开发中的内存管理是采用MRC(Manual Reference Counting)以及iOS5以后的ARC(Automatic Reference Counting),本质都是RC引用计数,通过引用计数的方式来管理内存的分配与释放,从而防止内存泄漏。

另外引用计数RC和垃圾回收GC是有区别的。垃圾回收是宏观的,对整体进行内存管理,虽然不同平台垃圾回收机制有异,但基本原理都是一样的:将所有对象看做一个集合,然后在GC循环中定时检测活动对象和非活动对象,及时将用不到的非活动对象释放掉来避免内存泄漏,也就是说用不到的垃圾对象是交给GC来管理释放的,而无需开发者关心,典型的是Java中的垃圾回收机制;相比于GC,引用计数是局部性的,开发者要管理控制每个对象的引用计数,单个对象引用计数为0后会马上被释放掉。ARC自动引用计数则是一种改进,由编译器帮助开发者自动管理控制引用计数(自动在合适的时机发送release和retain消息)。另外自动释放池autorelease pool则像是一个局部的垃圾回收,将部分垃圾对象集中释放,相对于单个释放会有一定延迟。

相关问题: 自动释放池跟GC(垃圾回收)有什么区别?iPhone上有GC么?[pool release]和[pool drain]有什么区别?

[pool release]和[pool drain]在作用上是一样的,都是倾倒自动释放池,区别是drain在支持GC垃圾回收的系统中(Mac系统)可以引起GC回收操作,而release不可以。对于自动释放池一般还是使用[pool drain]了,一是它的功能对系统兼容性更强,二者也是为了跟普通对象的release释放区别开。自动释放池的释放操作指的是向池内所有的对象发送release消息,以让系统及时释放池内的所有对象。 ***

问题: 如果一个对象释放前被加到了NotificationCenter中,不在NotificationCenter中remove这个对象可能会出现什么问题?

首先对于NotificationCenter的使用,我们都知道,只要添加对象到消息中心进行通知注册,之后就一定要对其remove进行通知注销。将对象添加到消息中心后,消息中心只是保存该对象的地址,消息中心到时候会根据地址发送通知给该对象,但并没有取得该对象的强引用,对象的引用计数不会加1。如果对象释放后却没有从消息中心remove掉进行通知注销,也就是通知中心还保存着那个指针,而那个指针指的对象可能已经被释放销毁了,那个指针就成为一个野指针,当通知发生时,会向这个野指针发送消息导致程序崩溃。 ***

问题: Objective-C是如何实现内存管理的?autorealease pool自动释放池是什么?autorelease的对象是在什么时候被release的?autorelease和release有什么区别?

引用计数

Objective-C的内存管理本质上是通过引用计数实现的,每次RunLoop都会检查对象的引用计数,如果引用计数为0,说明该对象已经没人用了,可以对其进行释放了。其中引用计数可以大体分为三种:MRC(手动内存计数)、ARC(自动内存计数,iOS5以后)和内存池。

其中引用计数是如何操作的呢?不论哪种引用计数方式,本质都是在合适的时机将对象的引用计数加1或者减1。

这里简单归结一下:

使对象引用计数加1的常见操作有:alloc、copy、retain

使对象引用计数减1的常见操作有:release、autorealease

自动释放池

自动释放池是一个统一来释放一组对象的容器,在向对象发送autorelease消息时,对象并没有立即释放,而是将对象加入到最新的自动释放池(即将该对象的引用交给自动释放池,之后统一调用release),自动释放池会在程序执行到作用域结束的位置时进行drain释放操作,这个时候会对池中的每一个对象都发送release消息来释放所有对象。这样其实就实现了这些对象的延迟释放。

自动释放池释放的时机,也就是自动释放池内的所有对象是在什么时候释放的,这里要提到程序的运行周期RunLoop。对于每一个新的RunLoop,系统都会隐式的创建一个autorelease pool,RunLoop结束时自动释放池便会进行对象释放操作。 
autorelease和release的区别主要是引用计数减一的时机不同,autorelease会在对象的使用真正结束的时候才做引用计数减1,而不是收到消息立马释放。

retain、release和autorelease的底层实现

最后通过了解这三者的较底层实现来理解它们的本质区别:

-(id)retain {
    /* 对象引用计数加1 */
    NSIncrementExtraRefCount(self);
    return self;
}

-(void)release {
    /* 对象引用计数减1,之后如果引用计数为0则释放 */
    if(NSDecrementExtraRefCountWasZero(self)) {
        NSDeallocateObject(self);
    }
}

-(id)autorelease {
    /* 添加对象到自动释放池 */
    [NSAutoreleasePool addObject:self];
    return self;
}

问题: 为什么很多内置的类,如TableViewController的delegate的属性是assign不是retain?

delegate代理的属性通常设置为assign或者weak是为了避免循环引用,所有的引用计数系统,都存在循环引用的问题,但也有个别特殊情况,个别类的代理例如CAAnimation的delegate就是使用strong强引用。

其他问法: 委托的property声明用什么属性?为什么? * **问题: CAAnimation的delegate代理是强引用还是弱引用?

CAAnimation的代理是强引用,是内存管理中的其中一个罕见的特例。我们知道为了避免循环引用问题,delegate代理一般都使用weak修饰表示弱引用的,而CAAnimation动画是异步的,如果动画的代理是弱应用不是强应用的话,会导致其随时都可能被释放掉。在使用动画时要注意采取措施避免循环引用,例如及时在视图移除之前的合适时机移除动画。

CAAnimation的代理定义如下,明确说了动画的代理在动画对象整个生命周期间是被强引用的,默认为nil。

/* The delegate of the animation. This object is retained for the
 * lifetime of the animation object. Defaults to nil. See below for the
 * supported delegate methods. */

@property(nullable, strong) id <CAAnimationDelegate> delegate;


问题: OC中,与alloc语义相反的方法是dealloc还是release?与retain语义相反的方法是dealloc还是release?需要与alloc配对使用的方法是dealloc还是release,为什么?

alloc与dealloc语意相反,alloc是创建变量,dealloc是释放变量;

retain与release语义相反,retain保留一个对象,调用后使变量的引用计数加1,而release释放一个对象,调用后使变量的引用计数减1。

虽然alloc对应dealloc,retain对应release,但是与alloc配对使用的方法是release,而不是dealloc。为什么呢?这要从他们的实际效果来看。事实上alloc和release配对使用只是表象,本质上其实还是retain和release的配对使用。alloc用来创建对象,刚创建的对象默认引用计数为1,相当于调用alloc创建对象过程中同时会调用一次retain使对象引用计数加1,自然要有对应的release的一次调用,使对象在不用时能够被释放掉防止内存泄漏。

此外,dealloc是在对象引用计数为0以后系统自动调用的,dealloc没有使对象引用计数减1的作用,只是在对象引用计数为0后被系统调用进行内存回收的收尾工作。


问题: 以下每行代码执行后,person对象的retain count分别是多少

Person *person = [[Person alloc] init];
[person retain];
[person release];
[person release];

1-2-1-0。开始alloc创建对象并持有对象,初始引用计数为1,retain一次引用计数加1变为2,之后release对象两次,引用计数减1两次,先后变为1、0。 ***

问题:执行下面的代码会发生什么后果? Ball *ball = [[[[Ball alloc] init] autorelease] autorelease];

程序会因其而崩溃,因为对象被加入到自动释放池两次,当对象被移除时,自动释放池将其释放了不止一次,其中第二次释放必定导致崩溃。


问题: 内存管理的几条原则是什么?按照默认法则,哪些关键字生成的对象需要手动释放?哪些对象不需要手动释放会自动进入释放池?在和property结合的时候怎样有效的避免内存泄露?

起初MRC中开发者要自己手动管理内存,基本原则是:谁创建,谁释放,谁引用,谁管理。其中创建主要始于关键词new、alloc和copy的使用,创建并持有开始引用计数为1,如果引用要通过发送retain消息增加引用计数,通过发送release消息减少引用计数,引用计数为0后对象会被系统清理释放。现在有了ARC后编译器会自动管理引用计数,开发者不再需要也不可以再手动显示管理引用计数。

当使用new、alloc或copy方法创建一个对象时,该对象引用计数器为1。不再需要使用该对象时可以向其发送release或autorelease消息,在其使用完毕时被系统释放。如果retain了某个对象,需要对应release或autorelease该对象,保持retain方法和release方法使用次数相等。

使用new、alloc、copy关键字生成的对象和retain了的对象需要手动释放。设置为autorelease的对象不需要手动释放,会直接进入自动释放池。


下面代码的输出依次为:

    NSMutableArray* ary = [[NSMutableArray array] retain];
    NSString *str = [NSString stringWithFormat:@"test"];
    [str retain];
    [ary addObject:str];
    NSLog(@"%@%d",str,[str retainCount]);
    [str retain];
    [str release];
    [str release];
    NSLog(@"%@%d",str,[str retainCount]);
    [ary removeAllObjects];
    NSLog(@"%@%d",str,[str retainCount]);
  • 2,3,1
  • 3,2,1(right)
  • 1,2,3
  • 2,1,3

此问题考查的是非MRC下引用计数的使用(只有在MRC下才可以通过retain和release关键字手动管理内存对象,才可以向对象发送retainCount消息获取当前引用计数的值),开始使用类方法stringWithFormat在堆上新创建了一个字符串对象str,str创建并持有该字符串对象默认引用计数为1,之后retain使引用计数加1变为2,然后又动态添加到数组中且该过程同样会让其引用计数加1变为3(数组的add操作是添加对成员对象的强引用),此时打印结果引用计数为3;之后的三次操作使引用计数加1后又减2,变为2,此时打印引用计数结果为2;最后数组清空成员对象,数组的remove操作会让移除的对象引用计数减1,因此str的引用计数变为了1,打印结果为1。因此先后引用计数的打印结果为:3,2,1。

这里要特别注意上面为何说stringWithFormat方法是在堆上创建的字符串对象,这里涉及到NSString的内存管理,下面单独对其进行扩展和分析。 OC中常用的创建NSString字符串对象的方法主要有以下五种:

    /* 字面量直接创建 */
    NSString *str1 = @"string";
    /* 类方法创建
    NSString *str2 = [NSString stringWithFormat:@"string"];
    NSString *str3 = [NSString stringWithString:@"string"]; // 编译器优化后弃用,效果等同于str1的字面量创建方式 */
    /* 实例方法创建 */
    NSString *str4 = [[NSString alloc] initWithFormat:@"string"];
    NSString *str5 = [[NSString alloc] initWithString:@"string"]; // 编译器优化后弃用,效果等同于str1的字面量创建方式

开发中推荐的是前两种str1和str2的创建方式,分别用来创建不可变字符串和格式化字符串。最新的编译器优化后弃用了str3的stringWithString和str5的initWithString创建方式,现在这样创建会报警告,说这样创建是多余的,因为实际效果和直接用字面量创建相同,也都是在常量内存区创建一个不可变字符串,由系统自动管理内存和优化内存,他们创建后可以认为已经被autorelease了。另外,此处由于字符串的内容都是“string”,使用str1、str3和str5创建的的字符串对象实际在常量内存区只有一个备份,这是编译器的优化效果,而str2和str4由于是在堆上创建因此各自有自己的备份。

此外最重要的是这五种方法创建的字符串对象所处的内存类型,str1、str3和str5都是创建的不可变字符串,是位于常量内存区的,由系统管理内存;stringWithFormat和initWithFormat创建的都是格式化的动态字符串对象,在堆上创建,需要手动管理内存。

相关问题:当你用stringWithString来创建一个新NSString对象的时候,你可以认为:

  • 这个新创建的字符串对象已经被autorelease了(right)
  • 这个新创建的字符串对象已经被retain了
  • 全都不对
  • 这个新创建的字符串对象已经被release了

问题:什么是安全释放?

释放掉不再使用的对象同时不会造成内存泄漏指针悬挂问题称其为安全释放。 ***

问题: 这段代码有什么问题,如何修改?

for (int i = 0; i < someLargeNumber; i++) {
	NSString *string = @”Abc;
	string = [string lowercaseString];
	string = [string stringByAppendingString:@"xyz"];
	NSLog(@“%@”, string);
}

代码通过循环短时间内创建了大量的NSString对象,在默认的自动释放池释放之前这些对象无法被立即释放,会占用大量内存,造成内存高峰以致内存不足。

为了防止大量对象堆积应该在循环内手动添加自动释放池,这样在每一次循环结束,循环内的自动释放池都会被自动释放及时腾出内存,从而大大缩短了循环内对象的生命周期,避免内存占用高峰。

代码改进方法是在循环内部嵌套一个自动释放池:

for (int i = 0; i < 1000000; i++) {
    @autoreleasepool {
        NSString *string = @"Abc";
        string = [string lowercaseString];
        string = [string stringByAppendingString:@"xyz"];
        NSLog(@"%@",string);
    }
}

相关问题: 这段代码有什么问题?会不会造成内存泄露(多线程)?在内存紧张的设备上做大循环时自动释放池是写在循环内好还是循环外好?为什么?

for(int index = 0; index < 20; index++) {
    NSString *tempStr = @”tempStr;
    NSLog(tempStr);
    NSNumber *tempNumber = [NSNumber numberWithInt:2];
    NSLog(tempNumber);
}

IOS中的协议Protocol与代理Delegate以及通知

Protocol和Delegate简介

Protocol协议类似于Java中的接口,是一个自定义方法的集合,让遵守这个协议的类去是实现为了达到某种功能的这些方法,与Java接口不同的是协议中可以定义可选择实现的方法。Delegate代理是一种设计模式,是一个概念,只不过在Objective-C中通过Protocol来进行实现,指的是让其他类来通过本类中定义的协议代理方法‘远程’帮助实现一些操作,完成一些任务,本类会在合适的时机通过代理通知实现协议的远程类去做指定的任务。

通过协议实现代理模式的示例

协议代理来源的本类:

#import <UIKit/UIKit.h>

/**
 * 定义协议
 */
@protocol AccountDelegate <NSObject>

@required // 必须实现的方法,默认是@required
/**
 * 选中cell的代理事件
 */
- (void) selectedCell:(NSInteger)index;

@optional // 非必须实现的方法
/**
 * 更新下拉菜单的高度
 */
- (void) updateListH;

@end

@interface PopListTableViewController : UITableViewController

/**
 * 定义代理,委托其他类来帮助本类完成一些其他任务,本类通过下面定义的delegate来通知其他实现上面协议的其他类
 */
@property (nonatomic, weak) id<AccountDelegate>delegate;

@end

然后本类在实现中通过定义的delegate通知其他遵守协议的类去执行某个方法:

// 通知代理,同时将cell的行号传出去
[_delegate selectedCell:indexPath.row];

然后遵守协议的类就可以收到上面的通知自动执行(注意是通知触发的方法执行,而不是手动调用方法)selectedCell这个方法了:

首先要在实现中声明遵守上面的协议:

// 遵守AccountDelegate协议,如果遵守多个协议用逗号隔开:<AccountDelegate, ...>
@interface PopMenuViewController()<AccountDelegate>

实现协议方法:

/**
 * 实现协议方法,监听代理,代理通知来了后下面的方法会自动执行,接收传过来的参数
 */
- (void)selectedCell:(NSInteger)index {
    // 这里可以做一些事情,也就是想委托当前这个类要做的那些任务了
    // ...
}

问题1: Objective-C中的协议和java中的接口概念有何不同?

Objective-C中的协议和java中的接口非常类似,但Java中的接口规定实现接口的类必须要实现接口中定义的所有方法,当然默认Objective-C协议中定义的方法也是要必须实现的,只不过Objective-C的协议里的方法有两种类型:必选类型(@required)和可选类型(@optional)。必须类型是必须要实现的,而可选类型是根据需要选择性实现的。默认是必选类型。


问题2: OC中协议的概念以及协议中方法的默认类型?

OC中的协议类似于Java中的接口,是一个功能方法的集合,但协议本身不是一个类不会自己去实现协议里的方法,而是委托其他任何类去使用实现,通常用来实现委托代理设计模式,实现不同类对象之间的事件消息通信。

协议中的方法默认都是@required类型的,也就是使用该协议的类必须实现协议里的这些方法。而明确使用@optional修饰的方法可以被使用的类选择性的去实现。


问题3: 什么是代理?作用是什么?

代理是一种设计模式,又叫‘委托’,指的是一个类对象在某些特定时刻通知到其他类的对象去做一些任务,但不需要获取到那些类对象的指针,两者共同来完成一件事,实现不同对象之间的通信。

作用主要是大大减小了对象之间的耦合度,是代码逻辑更加清晰有序,减少了框架复杂度,也便于代码的维护扩展。另外消息的传递过程可以有参数回调,类似于Java的回调监听机制,大大提高了编程的灵活性。


问题: 什么是推送消息?和Notification有什么不同?

消息推送指的是在App关闭不在前台运行时,向用户发送App的内部消息。消息推送通知和OC中的Notification通知机制不同,推送的消息是给用户看的,也就是可见的,而通知机制是OC语言中类间通信的一种机制,基于观察者模式,目的是触发内部事件,减小类之间的耦合度,而对用户是不可见的。

推送消息的可见形式主要有以下几种:

  • 锁屏界面的横幅推送消息;
  • 顶部通知栏的横幅推送消息;
  • 应用图标上的代表消息数量的红色数字;
  • 菜单页面弹出框提示;
  • 播放声音提示;

iOS开发中有两种类型的消息推送:本地消息推送(Local Notification)和远程消息推送(Remote Notification)。

本地消息推送: 本地推送很简单,不需要联网,不需要服务器,由客户端应用直接发出推送消息,一般通过定时器在指定的时间进行消息推送。

远程消息推送: 远程推送过程略为复杂,需要客户端从苹果的APNS(Apple Push Notification Services)服务器注册获得当前用户的设备令牌并发送给应用的服务器,然后应用的服务器才可以通过APNS服务器间接地向客户端发送推送消息,期间难免会有延迟。

远程推送的具体流程如下图所示,开发中要和服务器合作共同完成:

  • App客户端向APNS苹果服务器发送设备的UDID和Bundle Identifier;
  • APNS服务器对传过来的信息加密生成一个deviceToken,并返回给客户端;
  • 客户端将当前用户的deviceToken发送给自己应用的服务器;
  • 自己的服务器将得到的deviceToken保存,需要的时候利用deviceToken向APNS服务器发送推送消息;
  • APNS服务器接收到自己服务器的推送消息时,验证传过来的deviceToken,如果一致则将消息推送到客户端;

2.利用deviceToken进行数据传输,推送通知 https://i2.wp.com/way2ios.com/wp-content/uploads/2013/03/Push-Overview.jpg ***

问题: 什么是Notification?什么时候用Delegate,什么时候用Notification?

Notification通知是Cocoa框架中基于观察者模式实现的用于‘一对多’传播消息的一种机制。项目中的对象将它们自己或者其他对象添加到通知的观察者列表里(这个过程又叫通知注册),其中项目中的所有通知都有一个唯一的字符串标识作为通知名唯一确定每个通知,通知源也就是被观察者可以创建通知对象并发送到通知中心,通知中心找出所有注册该通知的对象(观察者),并将从被观察者那里收到通知以消息的方式发送给所有的观察者们。被观察者发送通知是一个同步过程,即发送者在通知中心成功将该发送者之前的消息发送给所有观察者之前不可以再次发送通知。另外通知触发的代理方法都必须符合某个单一参数签名约定,代理方法的参数是一个通知对象,参数里包含着通知名、被观察者和一个包含其他额外信息的字典。

Delegate和Notification的主要区别在于前者是一对一的消息传递,而后者是一对多的,可以根据这个特点在使用中进行选择。另外代理模式中,接受者reciever可以返回值给sender发送者,实现一种回调,而观察者模式中观察者不可以返回值给被观察者,因此在需要实现回调时只能选择代理模式。

其他问法:

  • delegate和notification的区别是什么?分别在什么情况下使用?
  • 通知Notification和协议Protocol的不同之处?

问题: NSNotification、Delegate、Block和KVO的对比?

Delegate代理是一种回调机制,是一对一的关系;而通知是基于观察者模式的一对多的关系,消息会发送给所有注册为事件观察者的对象;Delegate比Notification的执行效率要高;

Block和Delegate一样通常也是一对一的通知,使用场景相同,可以说Block是Delgate的另一种形式,但Block更加简洁直接且轻便灵活,不需要像Delegate那样需要定义协议很多方法,而且代理对象要实现协议方法,还需要建立对象间的代理关系才可以通信。在通信事件比较多的情况下,还是建议使用Delegate,Delegate的定义实现形式更加直观清楚。


KVO就是cocoa框架实现的观察者模式,一般同KVC搭配使用,通过KVO可以监测一个值的变化,比如View的高度变化。是一对多的关系,一个值的变化会通知所有的观察者。 NSNotification是通知,也是一对多的使用场景。在某些情况下,KVO和NSNotification是一样的,都是状态变化之后告知对方。NSNotification的特点,就是需要被观察者先主动发出通知,然后观察者注册监听后再来进行响应,比KVO多了发送通知的一步,但是其优点是监听不局限于属性的变化,还可以对多种多样的状态变化进行监听,监听范围广,使用也更灵活。

KVO一般的使用场景是数据,需求是数据变化,比如股票价格变化,我们一般使用KVO(观察者模式)。delegate一般的使用场景是行为,需求是需要别人帮我做一件事情,比如买卖股票,我们一般使用delegate。 Notification一般是进行全局通知,比如利好消息一出,通知大家去买入。delegate是强关联,就是委托和代理双方互相知道,你委托别人买股票你就需要知道经纪人,经纪人也不要知道自己的顾客。Notification是弱关联,利好消息发出,你不需要知道是谁发的也可以做出相应的反应,同理发消息的人也不需要知道接收的人也可以正常发出消息。 ***

UIView和CALayer

问题:UIView和CALayer的区别与联系是什么?UIWindow和UIView和CALayer 的联系和区别?

先看UIView和CALayer是什么

首先CALayer(层)是一个比UIView更底层的图形类,是对底层图形API(OpenGL ES)一层层封装后得到的一个类,用于展示一些可见的图形元素,保留了一些基本的图形化的操作,但同时由于相对高度的封装,使得操作使用变得很简单。CALayer用于管理图形元素,甚至可以制作动画,他保留了一些几何属性,例如:位置、尺寸、图形变换等等。一般的CALayer是作为UIView背后的支持角色,我们创建了一个UIView也同时存在一个相应的CALayer,UIView作为CALayer的代理角色去实现一些功能,例如常见的,我们为UIView制作一个圆角,就会用到UIView背后的layer操作:

view.layer.cornerRadius = 10

就是说CALayer可以通过UIView很方便的展示操作UI元素,但是CALayer自身单独也可以展示和操作可见元素,且灵活度更高,他自身有一些可见可设置的属性,例如:背景色,border边框,阴影等等。

另外UIView简单说是一个可以在里面渲染可见内容的矩形框,并且重要的是它里面的内容可以和用户进行交互,UIView可以对交互事件进行处理。除了其背后CALayer的图形操作支持,UIView自身也有像设置背景色等最基本的属性设置。

UIView和CALayer的联系

UIView和CALayer的主要联系上面已经提到,CALayer在UIView背后提供更加丰富灵活的图形操作,UIView作为CALayer的代理更加快速的帮CALayer显示一些常用的UI元素并提供交互。

UIView和CALayer的区别
  • 首先CALayer是比UIView更底层的低级类。UIView是UIKit框架中的高级类,属于顶层可交互高级类。而CALayer来自于QuartzCore.framework,是一个承载底层绘制内容的对象。
  • UIView和CALayer的最明显区别在于他们的可交互性,即UIView可以响应用户事件,而CALayer则不可以,原因可以从这两个类的继承关系上看出,UIView是继承自UIRespinder的,明显是专门用来处理响应事件的,而CALayer是直接继承自NSObject无法进行交互。 这里写图片描述
最后说一下UIWindow

从上面图中的继承关系会发现UIWindow居然是UIView的子类,因为UIWindow在应用中是作为根视图来承载UIView元素的,也就是说根父视图却是子视图的子类,有点违背直觉。

但事实就是这样,UIWindow提供一个区域(一般就是整个屏幕)来显示UIView,并且将事件分发给UIView。一个应用一般只有一个UIWindow,但特殊情况也会创建子UIWindow,例如实现一个始终漂浮在顶层的悬浮窗,就可以使用一个UIWindow来实现。


问题: 什么是层对象?除了CALayer系统还提供了那些常用的有绘制功能的layer类?

Layer层对象是用来展示可见内容的一种数据对象,常在视图中用来渲染视图内容。一般的层对象在界面中可以实现一些复杂的动画或者其他类型的一些复杂特效。

常见的几个其他自身具有绘制功能的专用layer还有:CATextLayer、CAShapeLayer、CAGradientLayer,这里给出使用示例。其他还有用于3D图形变换的CATransformLayer、实现滚动视图的CAScrollLayer、专门播放视频的AVPlayerLayer和制作粒子特效的CAEmitterLayer等等。他们都是继承自CALayer的,和CALayer一样都来自QuartzCore.framework框架。

CATextLayer:这个类是用来实现更加灵活的文字布局和渲染的,它几乎包含了UILabel的所有特性并在此基础上增加了很多更强大的功能,包括字体、尺寸、前景色和下划线等文字效果,同时CATextLayer的渲染效率明显高于UILabel。

通过CALayer来实现一个UIlabel的示例如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    /* 创建一个字符承载视图 */
    UIView *textView = [[UIView alloc] initWithFrame:CGRectMake(50, 50, 200, 50)];
    
    CATextLayer *text = [CATextLayer layer];
    text.frame = textView.frame;
    text.string = @"CAText";
    /* 文字前景色和背景色 */
    text.foregroundColor = [UIColor whiteColor].CGColor;
    text.backgroundColor = [UIColor blackColor].CGColor;
    /* 文字超出视图边界裁剪 */
    text.wrapped = YES;
    /* 文字字体 */
    text.font = (__bridge CFTypeRef)[UIFont systemFontOfSize:30].fontName;
    /* 文字居中 */
    text.alignmentMode = kCAAlignmentCenter;
    /* 适应屏幕retina分辨率,不然会像素化变模糊 */
    text.contentsScale = [[UIScreen mainScreen] scale];
    
    [textView.layer addSublayer:text];
    [self.view addSubview:textView];
    }

这里写图片描述

CAShapeLayer:用来专门绘制矢量图形的图形子类,例如可以指定线宽和颜色等利用CGPath绘制图形路径,可以实现图形的3D变换效果,渲染效率比Core Graphics快很多,而且可以在超出视图边界之外绘制,即不会被边界裁减掉。

这里展示使用CAShapeLayer绘制一个圆形的实例:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    /* 创建圆形路径 */
    UIBezierPath *path = [[UIBezierPath alloc] init];
    /* 起点要在圆心水平右侧半径长度处 */
    [path moveToPoint:CGPointMake(200, 100)];
    /* 添加圆形弧路径 */
    [path addArcWithCenter:CGPointMake(150, 100) radius:50 startAngle:0 endAngle:2*M_PI clockwise:YES];
    
    /* 创建图形层 */
    CAShapeLayer *layer = [CAShapeLayer layer];
    /* 路径线的颜色 */
    layer.strokeColor = [UIColor redColor].CGColor;
    /* 闭合图形填充色,这里设置透明 */
    layer.fillColor = [UIColor clearColor].CGColor;
    /* 线宽 */
    layer.lineWidth = 10;
    /* 线的样式:端点、交点 */
    layer.lineCap = kCALineCapRound;
    layer.lineJoin = kCALineJoinRound;
    /* 设置图形路径 */
    layer.path = path.CGPath;
    
    [self.view.layer addSublayer:layer];
    }
@end

这里写图片描述

CAGradientLayer:也是一个硬件加速的高性能绘制图层,主要用来实现多种颜色的平滑渐变效果。这里给出一个三种颜色从正方形左上角到右下角的渐变效果示例:

- (void)viewDidLoad {
    [super viewDidLoad];
    /* 创建layer承载视图 */
    UIView *containerView = [[UIView alloc] initWithFrame:CGRectMake(50, 50, 150, 150)];
    
    CAGradientLayer *layer = [CAGradientLayer layer];
    layer.frame = containerView.bounds;
    /* 依次设置渐变颜色数组 */
    layer.colors = @[(__bridge id)[UIColor greenColor].CGColor,(__bridge id)[UIColor yellowColor].CGColor,(__bridge id)[UIColor orangeColor].CGColor];
    /* 颜色从起点到终点按比例分段位置 */
    layer.locations = @[@0.0, @0.3, @0.5];
    /* 颜色渐变的起点和终点:(0,0)~(1,1)表示左上角到右下角 */
    layer.startPoint = CGPointMake(0, 0);
    layer.endPoint = CGPointMake(1, 1);
    
    [containerView.layer addSublayer:layer];
    [self.view addSubview:containerView];
}

这里写图片描述


问题: 以UIView类animateWithDuration:animations:为例,简述UIView动画原理?


问题: iOS中如何实现为UIImageView添加圆角?

iOS中圆角效果实现的最简单、最直接的方式,是直接修改View的layer层参数:

    /* 设置圆角半径 */
    view.layer.cornerRadius = 5;
    /* 将边界以外的区域遮盖住 */
    view.layer.masksToBounds = YES;

这种方法最简单快速,但其实这种方法的实现是靠的‘离屏渲染’(off-screen-rendering),性能很低。

另外一种则是实现on-screen-rendering,用于提高性能:

- (UIImage *)imageWithCornerRadius:(CGFloat)radius {
    CGRect rect = (CGRect){0.f, 0.f, self.size};
    UIGraphicsBeginImageContextWithOptions(self.size, NO, UIScreen.mainScreen
                                           .scale);
    CGContextAddPath(UIGraphicsGetCurrentContext(),
                     [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:radius].CGPath
                     );
    CGContextClip(UIGraphicsGetCurrentContext());
    [self drawInRect:rect];
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return image; }

SDWebImage图片二级缓存异步加载原理

关于SDWebImage

SDWebImage是一个针对图片加载的插件库,提供了一个支持缓存的用于异步加载图片的下载工具,特别的为常用的UI元素:UIImageView,UIButton和MKAnnotationView提供了Category类别扩展,可以作为一个很方便的工具。其中SDWebImagePrefetcher可以预先下载图片,方便后续使用.

SDWebImage的Github地址为:https://github.com/rs/SDWebImage

SDWebImage的几点特性

  • 为UIImageView,UIButton和MKAnnotationView进行了类别扩展,添加了web图片和缓存管理;
  • 是一个异步图片下载器;
  • 异步的内存+硬盘缓冲以及自动的缓冲过期处理;
  • 后台图片解压缩功能;
  • 可以保证相同的url(图片的检索key)不会被重复多次下载;
  • 可以保证假的无效url不会不断尝试去加载;
  • 保证主线程不会被阻塞;
  • 性能高;
  • 使用GCD和ARC

支持的图片格式

  • UIImage支持的图片格式(JPEG,PNG等等)包括GIF都可以被支持;
  • Web图片格式,包括动态的Web图片(使用WebP subspec)

使用方法示例

SDWebImage的使用非常简单,开发中需要的主要就是为一个UIImageView添加在线图片,用到的函数主要就是sd_setImageWithURL函数(新版本函数名都加了sd前缀),sd_setImageWithURL函数提供了几种重载方法,包括只使用图片URL参数的,以及设置占位图片placeholderImage参数的等等,这个函数也是框架封装的最顶层的应用函数,开发中实际主要就用这个函数即可,以这个函数为入口,可以层层打开往底层看,可以对应到SDWebImage的整个加载逻辑和流程。

Objective-C:

#import <SDWebImage/UIImageView+WebCache.h>
/* 使用SDWebImage框架为UIImageView加载在线图片 */
[imageView sd_setImageWithURL:[NSURL URLWithString:@"http://www.***.com/***/image.jpg"]
             placeholderImage:[UIImage imageNamed:@"placeholder.png"]];

Swift:

imageView.sd_setImageWithURL(NSURL(string: "http://www.***.com/***/image.jpg"), placeholderImage:UIImage(imageNamed:"placeholder.png"))

SDWebImage 加载图片的流程原理:

SDWebImage异步加载图片的使用非常简单,一个函数调用即可完成,但实际上这一个函数的调用会使得框架立刻完成一系列的逻辑处理,以最高效的方式加载需要的图片,具体加载流程逻辑如下:

这里写图片描述

根据流程可以知道,图片的加载采用了一种二级缓存机制,简单概括意思就是:能从内存缓存直接取就从内存缓存取,内存缓存没有就去硬盘缓存里取,再没有就根据提供的URL到网上下载(下载自然会慢很多),下载的图片还有一个解码的过程,解码后就可以直接用了,另外下载的图片会保存到内存缓存和硬盘缓存,从而下次再取同样的图片就可以直接取了而不用重复下载。

官方的架构图和时序图展示:

上面的整个流程对应到SDWebImage框架内部,依次会挖掘出下面几个关键函数,最外层的也就是我们直接调用的sd_setImageWithURL函数,以此函数为入口依次可能会调用到后面的函数,来完成上面的整个优化加载流程,这里以其中一个入口函数为例:

  1. sd_setImageWithURL: UIImageView(WebCache)的sd_setImageWithURL函数只是个UIView的类扩展接口函数,负责调用并将参数传给UIView(WebCache)的sd_internalSetImageWithURL函数,参数这里有图片的url和placeholder占位图片;

  2. sd_internalSetImageWithURL:UIView(WebCache)的sd_internalSetImageWithURL函数首先将placeholder展位图片异步显示,然后给SDWebImageManager单例发送loadImageWithURL消息,传给它url参数让其再给它的SDImageCache对象发送queryCacheOperationForKey消息先从本地搜索缓存图片;

  3. loadImageWithURL:收到loadImageWithURL消息后,SDWebImageManager单例向SDImageCache对象发送queryCacheOperationForKey消息开始在本地搜索缓存图片,SDImageCache对象先对自己发送imageFromMemoryCacheForKey消息从内存中搜索图片缓存,搜到则取出图片并通过SDCacheQueryCompletedBlock回调返回,否则再对自己发送diskImageForKey消息去硬盘搜索图片,搜到则取出图片通过SDCacheQueryCompletedBlock回调返回,内存和硬盘都搜不到则只好重新下载;

  4. downloadImageWithURL:如果本地搜索失败,SDWebImageManager会新建一个SDWebImageDownloader下载器,并向下载器发送downloadImageWithURL消息开始下载网络图片;下载成功并解码后一方面将图片缓存到本地,另一方面取出图片进行显示。其中像图片下载以及图片解码等耗时操作都是异步执行,不会拖慢主线程。

补充说明

SDImageCache在初始化的时候会注册一些消息通知,在内存警告或退到后台的时候会清理内存图片缓存,应用结束的时候会清理掉过期的图片。


问题:SDWebImage图片加载原理?

略。 ***

问题:SDWebImage在iOS9 3dtouch下出现的问题?

  • iOS9使用SDWebImage加载图片时,在SDWebImageDownloaderOperation.m文件内有操作可能在后台线程中更新UI,可能导致意想不到的结果甚至程序崩溃。因此在想更新UI时一定要保证将UI更新操作同步到主线程,UI更新同步到主线程有三种方法,推荐使用GCD来实现,样子如下:
dispatch_async(dispatch_get_main_queue(), ^{
    /* 这里写更新UI操作 */
    /* 写完UI更新操作要根据需要重新布局layout */
});
  • 另外iOS9考虑到http的不安全性,系统要求所有的网络请求都使用https,因此之前的http请求会失效报错,可以通过设置允许继续使用http解决,具体在工程的info.plist配置文件中添加如下配置即可(添加App Transport Security Settings配置字段在将其下的Allow Arbitary Loads的值改为YES):

问题: 网络图片处理问题中怎么解决一个相同的网络地址重复请求的问题?

可以通过建立一个以图片下载地址为key,以下载操作为value的字典,图片地址是唯一的,可以保证key值唯一。当需要加载该图片时,先根据key值去本地缓存中找,看该图片是否已经下载,如果key值匹配则直接从本地取图片资源从而避免重复下载操作,如果本地找不到则需要根据key值中的网络图片地址重新去网络上下载。 ***

问题: 在异步线程中下载很多图片,如果失败了,该如何处理?请结合RunLoop来谈谈解决方案。(提示: 在异步线程中启动一个RunLoop重新发送网络请求,下载图片)

  • 重新下载图片
  • 下载完毕, 利用RunLoop的输入源回到主线程刷新UIImageVIUew

问题: UIImage的imageNamed和imageWithContentsOfFile两种加载方法的主要区别是什么?如何选择?

首先两种方式的使用方法如下:

/* 1. 根据图片文件名加载,会缓存 */
UIImage *image = [UIImage imageNamed:@"icon"];   

/* 2. 根据文件路径加载,不缓存 */
NSString *filePath = [[NSBundle mainBundle] pathForResource:@"icon" ofType:@"png"];
UIImage *image = [UIImage imageWithContentsOfFile:filePath];
/* 另外对应还有一个等效的实例方法 */
UIImage *image = [[UIImage alloc]initWithContentsOfFile:filePath];

主要区别是使用imageNamed方法会自动缓存新加载的图片并会重复利用缓存的图片,而imageWithContentsOfFile直接根据路径加载图片没有缓存和取缓存的过程。imageNamed首先根据指定的图片资源名称在系统缓冲中搜索图片资源,找到即返回资源,找不到然后才到硬盘等地方重新加载图片资源并缓存。imageWithContentsOfFile和imageWithData类似,不会缓存图片,将图片转化成数据对象进行加载。

关于两者的选择主要考虑它们是否缓存的特点,对于那些尺寸较小且反复使用的图片资源我们会选择imageNamed方法,利用缓存加快加载速度。同时缓存太多又会占用太多空间,因此对于那些尺寸很大且不常用甚至只用一次的图片,应该选择使用imageWithContentsOfFile方法加载,不进行缓存。另外注意imageWithContentsOfFile不可以直接加载Assets.xcassets图集里的图片,而需要将图片拖入工程目录。 ***

iOS中的KVC与KVO,NSNotification通知

问题: 什么是键值编码KVC,键路径是什么? 什么是键值观察KVO?

键值编码KVC: 键值编码是一种在NSKeyValueCoding非正式协议下使用字符串标志间接访问对象属性的一种机制,也就是访问对象变量的一种特殊的捷径。如果一个对象符合键值编码的约定,那么它的属性就可以通过一个准确的、唯一的字符串(键路径字符串)参数进行访问,类似于将所有对象看做字典Dictionary,键路径为key(实际为keypath),属性值即value,通过键路径访问属性值。键值编码的间接访问方式其实是传统实例变量的存取方法访问的一种替代,也就是另外一种可以访问对象变量的方法。其中注意键值编码可以暴力访问对象的任何变量,无论是否是pirvate私有类型的变量。

通常我们是通过存取方法来访问对象的属性的,getter方法返回属性的值,setter方法设置属性的值。对于实例对象,我们可以直接通过存取方法或者变量名来访问对象的属性,但是随着属性数量的增加和对象变量的嵌套深度增大,访问代码会随之增多。相比之下,通过键值编码就可以简洁而稳定的对所有属性进行访问。

键值编码是Cocoa框架中很基础的一个概念,像KVO键值观察、Cocoa绑定、Core Data等都是基于KVC的。

键路径: 键路径就是键值编码中某个属性的key,一个由连续键名组成的字符串,键名即属性名,键名之间用点隔开,用于指定一个连接在一起的对象性质序列。键路径使我们可以独立于模型实现的方式指定相关对象的性质。通过键路径,可以指定对象图中的一个任意深度的路径,使其指向相关对象的某个特定的属性。

键值观察KVO: 键值观察,是基于键值编码实现的一种观察者机制,提供了观察某一属性变化的监听方法,用来简化代码,优化逻辑和组织。

这里提供一个最基本的kvo和kvc的使用示例。假设有一个专业类Major,Major有一个专业名majorName私有属性;一个学生类Student,Student有一个姓名name私有属性,同时还有一个专业Major对象变量,这样就出现了简单的Major和Student的对象嵌套。Major和Student都继承自NSObject都符合键值编码约定,可以定义一个Student变量对其进行键值编码和键值观察:

// 1.专业类模型:Major.h
@interface Major : NSObject {
    @private
    NSString *majorName; // 私有实例变量专业名称
}
@end

// 2.学生类模型:Student.h
@interface Student : NSObject {
    @private
    NSString *name; // 私有实例变量姓名
}
@property (nonatomic, strong)Major *major; // 学生专业

// 3.kvc和kvo之间基本使用方法
#import "Student.h"
#import "Major.h"

@interface ViewController ()

@property (nonatomic, strong)Student *student;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 初始化学生Student对象
    _student = [[Student alloc] init];
    // 初始化学生的专业Major对象
    _student.major = [[Major alloc] init];
    
    // 1.set: 通过KVC设置Student对象的值(可以强行访问private变量)
    [_student setValue:@"Sam" forKey:@"name"];
    [_student setValue:@"Computer Science" forKeyPath:@"major.majorName"];
    
    // 2.get: 通过KVC读取Student对象的值(可以强行访问private变量)
    NSLog(@"%@", [_student valueForKey:@"name"]);
    NSLog(@"%@", [_student valueForKeyPath:@"major.majorName"]);

    // 3.kvo:添加当前控制器为键路径major.majorName的一个观察者,如果major.majorName的值改变会通知当前控制器从而自动调用下面的observeValueForKeyPath函数,这里传递旧值和新值
    [_student addObserver:self forKeyPath:@"major.majorName" options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew context:nil];
    
    // 3s后改变major.majorName的值
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [_student setValue:@"Software Engineer" forKeyPath:@"major.majorName"];
    });
}

/**
 * 监听keyPath值的变化
 */
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqual:@"major.majorName"]) {
        // 获取变化前后的值并打印
        NSString *oldValue = [change objectForKey:NSKeyValueChangeOldKey];
        NSString *newValue = [change objectForKey:NSKeyValueChangeNewKey];
        NSLog(@"major.majorName value changed:oldValue:%@ newValue:%@", oldValue,newValue);
    }
    else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

/**
 * 移除观察者释放资源,防止资源泄漏
 */
- (void)dealloc {
    [_student removeObserver:self forKeyPath:@"major.majorName"];
}
@end

可以看到‘major.majorName’就是一个由两个键连接起来的基本键路径,相当于用它可以直接访问属性的属性;使用addObserver函数将当前控制器注册为Student对象的观察者,当Student中键路径‘major.majorName’下的值发生改变时会通知当前控制器,触发observeValueForKeyPath监听函数。 ***

问题: NSNotification是同步还是异步? KVO是同步还是异步?NSNotification是全进程空间的通知吗?KVO呢?

NSNotification默认在主线程中通知是同步的,当通知产生时,通知中心会一直等待所有的观察者都收到并且处理通知结束,然后才会返回到发送通知的地方继续执行后面的代码;但可以将通知的发送或者将通知的处理方法放到子线程中从而避免通知阻塞。其中通知的发送可以添加到NSNotificationQueue异步通知缓冲队列中,也不会导致通知阻塞。NSNotificationQueue是一个通知缓冲队列,通常以FIFO先进先出的规则维护通知队列的发送,向通知队列添加通知有三种枚举类型:NSPostASAP、NSPostWhenIdle、和NSPostNow,分别表示尽快发送、空闲时发送和现在立刻发送,可以根据通知的紧急程度进行选择。

下面示例验证默认通知是同步的:

// 自定义消息的名称
#define MYNotificationTestName @"NSNotificationTestName"

// 1.注册通知的观察者
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(process) name:MYNotificationTestName object:nil];
    
// 2.发出通知给观察者
NSLog(@"即将发出通知!");
[[NSNotificationCenter defaultCenter] postNotificationName:MYNotificationTestName object:nil];
NSLog(@"发出通知处的下一条代码!");
    
/**
 * 3.处理收到的通知
 */
- (void)process {
    sleep(10); // 假设处理需要10s
    NSLog(@"通知处理结束!");
}

打印结果:

2017-01-21 22:21:30.501 SingleView[4579:146073] 即将发出通知!
2017-01-21 22:21:40.572 SingleView[4579:146073] 通知处理结束!
2017-01-21 22:21:40.572 SingleView[4579:146073] 发出通知处的下一条代码!

打印“即将发出通知”后,等了10s之后才打印出“通知处理结束”,然后才打印出@“发出通知处的下一条代码!”,“发出通知处的下一条代码!”是等到通知处理结束才打印出来的,说明通知是同步的。

可以通过将通知的发送语句或者通知的处理语句放到子线程实现通知的异步:

将通知的发送语句放到子线程:

NSLog(@"即将发出通知!");
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    [[NSNotificationCenter defaultCenter] postNotificationName:MYNotificationTestName object:nil];
});
NSLog(@"发出通知处的下一条代码!");

或者:

NSLog(@"即将发出通知!");
// 将通知放到通知异步缓冲队列
NSNotification *notification = [NSNotification notificationWithName:MYNotificationTestName object:nil];
[[NSNotificationQueue defaultQueue] enqueueNotification:notification postingStyle:NSPostASAP];
NSLog(@"发出通知处的下一条代码!");

将通知的处理放到子线程:

/**
 * 处理收到的通知
 */
- (void)process {
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        sleep(10); // 假设处理需要10s
        NSLog(@"通知处理结束!");
    });
}

执行结果变为:

2017-01-21 22:31:09.259 SingleView[4711:151180] 即将发出通知!
2017-01-21 22:31:09.260 SingleView[4711:151180] 发出通知处的下一条代码!
2017-01-21 22:31:19.290 SingleView[4711:151252] 通知处理结束!

类似的,KVO也是同步的。 ***

http://www.cnblogs.com/stoic/archive/2012/07/20/2601315.html

http://magicalboy.com/kvc_and_kvo/


问题:KVC(Key-Value-Coding)内部的实现:

一个对象在调用setValue的时候:

  • 首先根据方法名找到运行方法的时候所需要的环境参数;
  • 他会从自己isa指针结合环境参数,找到具体的方法实现的接口;
  • 再直接查找得来的具体的方法实现。

KVO(Key-Value- Observing):当观察者为一个对象的属性进行了注册,被观察对象的isa指针被修改的时候,isa指针就会指向一个中间类,而不是真实的类。所以isa指针其实不需要指向实例对象真实的类。所以我们的程序最好不要依赖于isa指针。在调用类的方法的时候,最好要明确对象实例的类名

iOS中的Category类别

问题: OC中类别(Category)是什么?

Category类别是Objective-C语言中提供的一个灵活的类扩展机制。类别用于在不获悉、不改变原来代码的情况下往一个已经存在的类中添加新的方法,只需要知道这个类的公开接口,而不需要知道类的源代码。类别只能为已存在的类添加新的功能扩展方法,而不能添加新的属性。类别扩展的新方法有更高的优先级,会覆盖同名的原类的已有方法。


问题: Category类别与其他特性的比较?

与继承Inheritance的比较:

1.子类继承是进行类扩展的另一种常用方法,当然基于子类继承的扩展更加自由、正式,既可以扩展属性也可以扩展方法。类别可以在不获悉、不改变原来代码的情况下往里面添加新的方法,但也只能添加方法,不能添加属性,属于功能上的扩展。类别扩展的优点是不需要创建一个新的类,而是在系统中已有的类上直接扩展、拼装,不需要更改系统类就可以添加并使用扩展方法。

2.相对于子类继承扩展,类别的另一明显优势是实现了功能的局部化封装,扩展的功能只会在本类被引用时看到。例如,假设原类为UIButton,现在要使用类别扩展一些用于模块A的方法,那么这些扩展方法就可以定义在一个叫做UIButton+A.h的头文件中,只有在引用UIButton+A.h的地方,才能看到原UIButton类有模块A添加的那些扩展方法,如果不需要模块A的功能,不引用UIButton+A.h头文件就看不到UIButton的那些扩展方法的存在,实现扩展模块的隔离。

与扩展Extension的比较:

Category类别和Extension扩展的明显不同在于,后者可以添加属性。另外后者添加的方法是必须要实现的。Extension可以认为是一个私有的匿名的Category,因为Extension定义在.m文件头部,添加的属性和方法都没有暴露在头文件,在不考虑运行时特性的前提下这些扩展属性和方法只能类内部使用,一定程度上可以说是私有的。 ***

问题: 类别有什么作用和好处?类别的局限性和使用注意事项?

作用:

  • 可以将类的实现分散到多个不同文件或多个不同框架中(扩充新的方法);
  • 可以创建对私有方法的前向引用;
  • 可以向对象添加非正式协议;

局限性:

  • 类别只能向原类中添加新的方法,且只能添加而不能删除或修改原方法,不能向原类中添加新的属性;

  • 类别向原类中添加的方法是全局有效的而且优先级相对最高,如果和原类的方法重名,会无条件覆盖掉原来的方法,造成难以发现的潜在危险,因此使用类别添加方法一定注意保证是单纯的添加新方法,避免覆盖原来的方法(可以通过添加该类别的方法前缀来防止冲突),否则原方法被类别覆盖了,团队中其他成员不知情的情况下用到这个被覆盖的方法会出现意想不到的问题而难以察觉纠正。


问题: Category类别和Extension类扩展的使用方法?

类别和扩展的区别我们已经知道了,最明显的是类别不可以添加新属性而类扩展可以。类别的使用方法很简单,就是新建某个类的类别扩展文件,然后添加新的方法。而类扩展并不常用,只是常用在.m文件中的头部进行头文件的私有属性变量补充,也就是所谓的类的Continuous区域,是将不想暴露给外部的一些变量定义在类扩展中。

创建类别或扩展文件:

为工程添加新文件并选择Objective-C File:

填写自定义的扩展名后缀,然后选择文件类型,这里选择Category类别或者Extension扩展,最后选择要扩展的已有类:

创建之后得到对应的类别文件:

添加新的扩展方法:

这里以扩展NSString类的方法为例展示类别扩展的具体用法,分别添加一个类方法和实例方法,并在需要的地方调用:

类别头文件方法声明:

/* NSString+Category.h */
#import <Foundation/Foundation.h>

@interface NSString (Category) {
    /* 不可以添加实例变量,编译器会直接报错!*/
}

/* 实例变量被禁止,此处添加属性变量是无意义的,定义的新的属性,编译器没有实现存取方法,自己也无法手动实现存取方法,因为无法获取加下划线的实例变量,除非利用运行时强行实现存取方法则可以成功为类别添加属性,代价较高 */
//@property (nonatomic, copy) NSString *newString;

/**
 * 扩展一个类方法
 */
+ (void)categoryClassMethodOfString;

/**
 * 扩展一个实例方法
 */
- (void)categoryInstanceMethodOfString;

@end

类别方法实现,类别的方法可以不实现,不实现则不可调用否则会崩溃:

/* NSString+Category.m */
#import "NSString+Category.h"

@implementation NSString (Category)

+ (void)categoryClassMethodOfString {
    NSLog(@"categoryClassMethodOfString");
}

- (void)categoryInstanceMethodOfString {
    NSLog(@"categoryInstanceMethodOfString");
}

@end

在需要的类中引入类别头文件NSString+Category.h,然后即可调用新方法:

#import "NSString+Category.h"
/* 1.调用类别扩展的类方法 */
[NSString categoryClassMethodOfString];
    
/* 2.调用类别扩展的实例方法 */
NSString *string = [NSString stringWithFormat:@""];
[string categoryInstanceMethodOfString];

类扩展的一般用法:

类扩展即类的.m文件中@implementation之前开始的部分,所谓的类的continuous区域:

@interface class name ()
// ...
@end

类扩展的作用本来是用于私有函数的前向声明,但最新编译器无需声明也有相同的效果,因此私有方法可在.m文件中任意位置直接写实现而无需在此处进行前向声明,如果在此处声明函数那么一定要在后面进行实现,否则编译器会给出警告。现在类扩展区域的作用主要是快速定义类的私有属性,即将暴露给外部的属性变量定义在头文件中,而不想暴露给外部的属性则直接定义在类扩展区域。【注意这里的私有属性和私有方法并不是绝对私有的,OC中没有绝对的私有方法和私有变量,因为即使它们隐藏在.m实现文件里不暴露在头文件中,开发者仍然可以利用runtime运行时机制对其暴力访问,只是一般情况可以达到私有的效果】

/* 类扩展区域 */
@interface ViewController ()

/* 类扩展属性(默认为private, 类外部不可访问) */
@property (nonatomic,copy) NSString *extensionVariable;

/**
 * 类扩展方法声明,可省略,要在立刻在后面进行函数实现
 */
- (void)extensionInstanceMethod;
+ (void)extensionClassMethod;

@end

***

问题: 为什么类别只能添加扩展方法而不能添加属性变量?

这个问法有点歧义,实际问的是为什么向类别添加属性会失败,而非官方为何有意不让类别扩展属性,如果是有意,则可能从设计上考虑保持类别特性的单纯,专门用来扩展功能,和继承的角色区别开,防止类别污染被扩展的类。

话说回来,在类别中扩展属性不能成功的原因是无法在类别中取得属性的加下划线的实例变量名,导致无法手动实现实例变量的存取方法。在类别中定义了属性后,属性其实也成功添加到了类的属性列表中,但编译器只为其声明了存取方法,没有实现,同时又没有合成加下划线的实例变量名,导致无法访问实例变量也无法自己手动实现其存取方法,对于一个不能访问的属性则失去了存在的意义。

但是如果使用运行时的武器,我们其实可以强行实现类别中属性的存取方法,实现在类别中扩展属性。这里在运行时,实现为NSString类扩展一个叫做newString的属性:

/* NSString+Category.h */
#import <Foundation/Foundation.h>

@interface NSString (Category)
/* 在类别中扩展属性 */
@property (nonatomic, copy) NSString *newString;
@end
/* NSString+Category.m */
#import "NSString+Category.h"
#import <objc/runtime.h>

@implementation NSString (Category)
/**
 * 运行时强行实现newString的getter和setter
 */
- (NSString *)newString {
    return objc_getAssociatedObject(self, @"newString");
}
- (void)setNewString:(NSString *)newString {
    objc_setAssociatedObject(self, @"newString", newString, OBJC_ASSOCIATION_COPY);
}
@end
/* main.m */
#import <Foundation/Foundation.h>
#import "NSString+Category.h"

int main(int argc, const char * argv[]) {
    NSString *string;
    /* 调用newString的setter方法 */
    string.newString = @"newString";
    /* 调用newString的getter方法 */
    NSLog(@"%@",string.newString);
    return 0;
}

问题: iOS中什么是‘扮演者’?

Objective-C允许应用中的一个类完全替代另一个类,并称其为替代类‘扮演’了被替代的类。所有本来发送给被替代的类的消息都会转而被‘扮演者’类所接收,消息也不需要在发送给‘扮演者’之前先发给被替代的类了。对于类的‘扮演’也有一些限制:一个类可能只能扮演一个它的直接或间接父类,‘扮演者’类不能再定义被替代类所没有的新的实例变量(但是可以定义或者重写方法)。

‘扮演’,和Category类别有类似之处,都允许扩展已有的类,但‘扮演’有两个类别所没有的特性:

  • 一个‘扮演者’类可以通过super关键字调用父类中已经覆盖了的方法,因此可以辅助被替代类的功能实现;
  • 一个‘扮演者’类可以覆盖在类别中定义的方法,优先级更高;

Objective-C中的单例模式

单例模式

单例模式是一种最基本的常用设计模式,单例类在系统中只有一个实例,通过一个全局接口随时进行访问或者更新,起到控制中心的角色,全局协调类的各种服务。

Cocoa框架中常用的单例对象

  • UIApplication:应用程序实例对象,一个UIApplication对象就代表一个应用程序,每个应用程序有且只有一个UIApplication对象,开发中最常用的是使用它的openURL函数来跳转到其他应用程序,通过[UIApplication sharedApplication]类方法可以获得这个单例对象;
  • NSNotificationCenter:通知中心,iOS中的通知中心是一种消息的广播,采用了观察者模式,同时应一个应用有且只有一个默认通知中心,也就是一个单例,默认的通知中心可通过[NSNotificationCenter defaultCenter]类方法获得;
  • NSFileManager:文件管理器,iOS文件系统的接口,用来创建、修改、访问文件,默认文件管理器单例可通过[NSFileManager defaultManager]类函数获得;
  • NSUserDefaults:应用程序用户偏好设置,主要用来存储简单键值对数据,数据持久化最简单基础的一种方案,其单例类可通过[NSUserDefaults standardUserDefaults]类函数获得;
  • NSWorkspace:一个比较宏观的应用级控制中心单例类,可以用来打开或操作文件和设备,以及获取文件和设备的信息,跟踪文件或设备以及数据库的变动,设置或获取文件的Finder信息,还可以启动应用程序,可通过[NSWorkspace sharedWorkspace]类函数获得单例;
  • NSURLCache:iOS中设置内存缓存的一个单例类,可通过[NSURLCache sharedURLCache]类函数获得使用;
  • NSHTTPCookieStorage:iOS中的一个管理cookie的单例对象。

OC中单例模式的实现

iOS中单例模式的实现主要要考虑两种情况,一种是非ARC下的实现(要考虑内存管理),一种是ARC下进行实现,但目的相同都是实现让某个类在应用中有且只有一个实例。这里只说ARC下的实现方法。假设规定就通过类名的类函数来调用单例类,不允许通过alloc和init创建,也暂时不考虑截断copyWithZone的问题,从而简单实现。但实际上可能会被通过其他方式重新初始化创建一个新的对象,为了阻止其发生,另外要考虑将其他创建方式进行重写截断,保证对象只会按照我们预想的被实例化一次。

传统的约定俗成的单例实现方式是类似于下面这样的(静态变量实现):

+ (instancetype)sharedInstance
{
    static id sharedInstance;
    @synchronized(self) {
        if (sharedInstance == nil) {
            sharedInstance = [[SingletonClass alloc] init];
        }
    }
    return sharedInstance;
}

现在有了OC中的GCD还是推荐使用dispatch_once()来控制代码同步从而取代上面的实现方法的。dispatch_once()优点是简洁高效,而且含义符合我们这里的需求,就是‘只执行一次’,此外还能避免线程不安全等潜在问题。Singletons: You’re doing them wrong

#import <Foundation/Foundation.h>

@interface SingletonClass : NSObject

// 测试变量
@property (nonatomic, copy)NSString *name;
// class单例
+ (instancetype *)sharedInstance;

@end
#import "SingletonClass.h"

@implementation SingletonClass

// class单例
+ (instancetype *)sharedInstance {
    static dispatch_once_t once = 0;
    static id sharedInstance = nil;
    dispatch_once(&once, ^{
        // 只实例化一次
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

// 测试
- (void)test {
    // 可在整个工程中调用如下代码:
    [SingletonClass sharedInstance].name = @"sharedInstnce";
    NSString *name = [SingletonClass sharedInstance].name;
}

@end

问题:什么是单例模式?

Foundation和Application Kit框架中的一些类只允许创建单件对象,即这些类在当前进程中的唯一实例。举例来说,NSFileManager和NSWorkspace类在使用时都是基于进程进行单件对象的实例化。当向这些类请求实例的时候,它们会传递单一实例的一个引用, 如果该实例还不存在,则首先进行实例的分配和初始化。单件对象充当控制中心的角色,负责指引或协调类的各种服务。如果类在概念上只有一个实例(例如NSWorkspace),就应该产生一个单件实例,而不是多个实例;如果将来某一天可能有多个实例,可以使用单件实例机制,而不是工厂方法或函数。

UITableView实现分组可折叠下拉列表

前言

UITableView作为UIKit中最重要的一个组件,应用还是很广泛很灵活的,它的特性用来实现分组列表再合适不过。可折叠分组列表最典型的是好友列表,是一个二级目录,点击每一个分组都会展开或折叠一个好友列表。

这里使用TableView的section header作为分组一级目录,每个section的cell作为二级目录。section header里面放的是一个自定义的UIButton,button的图片设置为折叠指示箭头,button的文字为分组名称,为这个button定义代理,点击按钮时通知刷新对应的section的数据即可展开或者折叠该分组。

这里写图片描述

自定义section header头部按钮以及协议

头部按钮的制作主要有三点:一个是定义点击后的通知代理,二是调整按钮图片和文字的frame成左图右文字的样式(通过contentRect代理回调来调整),三是点击后按钮图片的旋转动画(tranform旋转90)。

//
//  SectionHeaderView.h
//  JXHDemo
//
//  Created by 919575700@qq.com on 10/23/15.
//  Copyright (c) 2015 Jiangxh. All rights reserved.
//  section头部视图,是一个buntton

#import <UIKit/UIKit.h>

@class SectionHeaderView;
/**
 * 自定义协议
 */
@protocol SectionHeaderDelegate <NSObject>

//点击了section header
- (void)sectionDidClicked:(SectionHeaderView *)sender;

@end

@interface SectionHeaderView : UIButton

/**
 *  记录是否已经展开
 */
@property (nonatomic)BOOL isOpen;

/**
 *  协议
 */
@property (nonatomic, weak) id<SectionHeaderDelegate>delegate;

@end

//
//  SectionHeaderView.m
//  JXHDemo
//
//  Created by 919575700@qq.com on 10/23/15.
//  Copyright (c) 2015 Jiangxh. All rights reserved.
//
#define sectionMargin 10
#define sectionIconSize 20
#import "SectionHeaderView.h"

@interface SectionHeaderView()

@end

@implementation SectionHeaderView

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        //设置按钮属性
        [self setButton];
    }
    return self;
}

/**
 *  设置按钮属性
 */
- (void)setButton {
    _isOpen = NO;
    //背景色
    self.backgroundColor = [UIColor whiteColor];
    // 文字颜色
    [self setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
    
    // 添加指示图片
    [self setImage:[UIImage imageNamed:@"arrow"] forState:UIControlStateNormal];
    // 图片模式
    self.imageView.contentMode = UIViewContentModeScaleAspectFit;
    
    // 添加下分割线
    UIImageView *underLine = [[UIImageView alloc] initWithFrame:CGRectMake(0, self.frame.size.height-1, self.frame.size.width, 1)];
    // 贴图
    [underLine setImage:[UIImage imageNamed:@"line"]];
    //改变线的透明度
    [underLine setAlpha:0.3];
    [self addSubview:underLine];
    
    // 添加点击事件
    [self addTarget:self action:@selector(clicked:) forControlEvents:UIControlEventTouchUpInside];

}

/**
 *  点击
 */
- (void)clicked:(SectionHeaderView *)sender {
    
    // 如果没展开,顺时针旋转90度
    if (!_isOpen) {
        [sender.imageView setTransform:CGAffineTransformMakeRotation(M_PI_2)];
    }
    // 如果展开了,逆时针旋转90度
    else {
        [sender.imageView setTransform:CGAffineTransformMakeRotation(-M_PI_2)];
    }
    
    // 通知代理
    [_delegate sectionDidClicked:sender];

    // 状态取反
    _isOpen = !_isOpen;
}

/**
 *  返回代理需要的标题尺寸和图片尺寸
 */
- (CGRect)titleRectForContentRect:(CGRect)contentRect {
    return CGRectMake(55, 0, contentRect.size.width, contentRect.size.height);
}
- (CGRect)imageRectForContentRect:(CGRect)contentRect {
    return CGRectMake(20, 5, contentRect.size.height-10, contentRect.size.height-10);
}

@end

自定义cell

cell的样式根据需要可以随便设计,这里设置一个最简单的:一个头像,一个名字。

//
//  AccountCell.h
//  JXHDemo
//
//  Created by Xinhou Jiang on 3/11/16.
//  Copyright © 2016年 Jiangxh. All rights reserved.
//

#import <UIKit/UIKit.h>

@interface AccountCell : UITableViewCell

// 头像
@property (nonatomic, strong) UIImageView *avatar;

// 昵称
@property (nonatomic, strong) UILabel *name;

@end

//
//  AccountCell.m
//  JXHDemo
//
//  Created by Xinhou Jiang on 3/11/16.
//  Copyright © 2016年 Jiangxh. All rights reserved.
//
#define cellH 40 // cell高度
#define ApplicationW [UIScreen mainScreen].bounds.size.width // 屏幕宽度

#import "AccountCell.h"

@implementation AccountCell

-(instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        // 头像
        _avatar = [[UIImageView alloc]initWithFrame:CGRectMake(5, 5, cellH-10, cellH-10)];
        _avatar.layer.cornerRadius = (cellH-10)/2;
        [self.contentView addSubview:_avatar];
        
        // 账号
        _name = [[UILabel alloc]initWithFrame:CGRectMake(cellH, 0, ApplicationW - cellH, cellH)];
        _name.font = [UIFont systemFontOfSize:12.0];
        [self.contentView addSubview:_name];
    }
    
    return self;
}

@end

数据模型

数据主要是section分组的一个数据数组和每个分组的cell数据数组,这里为了简单固定返回了一组死数据,具体有了数据源在request的函数内将数据对应接入即可。

/**
 *  记录section的展开状态
 */
@property (nonatomic, strong)NSMutableArray *isOpen;

/**
 *  记录section的标题数组
 */
@property (nonatomic, strong)NSArray *titles;

/**
 *  请求数据
 */
- (void)initValue {
    // 标题数组假数据
    _titles = @[@"朋友", @"同学", @"家人", @"同事"];
    
    // 初始化所有section都是折叠状态
    _isOpen = [[NSMutableArray alloc] initWithCapacity:_titles.count];
    for (int i = 0; i<_titles.count; i++) {
        [_isOpen addObject:@NO];
    }
}


/**
 *  cell
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    
    static NSString *identifier = @"identifier";
    // 具体可以自制cell组件
    AccountCell *cell = [[AccountCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
    // 头像,具体应该从好友数据中取
    [cell.avatar setImage:[UIImage imageNamed:@"male"]];
    // 昵称,具体应该从好友数据中取
    cell.name.text = @"夏明";
    //cell颜色
    //cell.backgroundColor = RGBColor(200, 200, 200);
    
    return cell;
}

UITableView分组下拉列表

主体就是TableView的一个完整应用,包括section和cell的管理。

//
//  FolderTableViewController.h
//  JXHDemo
//
//  Created by 919575700@qq.com on 10/23/15.
//  Copyright (c) 2015 Jiangxh. All rights reserved.
//  可折叠section的表格

#import <UIKit/UIKit.h>

@interface FolderTableViewController : UITableViewController

@end

//
//  FolderTableViewController.m
//  JXHDemo
//
//  Created by 919575700@qq.com on 10/23/15.
//  Copyright (c) 2015 Jiangxh. All rights reserved.
//
#define sectionHeaderH 30 //组头部的高度
#define ApplicationW [UIScreen mainScreen].bounds.size.width // 屏幕宽度
#define RGBColor(r, g, b) [UIColor colorWithRed:(r)/255.0 green:(g)/255.0 blue:(b)/255.0 alpha:1.0] // 通过RGB创建颜色

#import "FolderTableViewController.h"
#import "SectionHeaderView.h"
#import "AccountCell.h"

@interface FolderTableViewController ()<SectionHeaderDelegate>

/**
 *  记录section的展开状态
 */
@property (nonatomic, strong)NSMutableArray *isOpen;

/**
 *  记录section的标题数组
 */
@property (nonatomic, strong)NSArray *titles;

@end

@implementation FolderTableViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // 表格基本设置
    self.title = @"可展开的TableView";
    self.view.backgroundColor = RGBColor(240, 240, 240);
    // 清除底部多余cell
    [self.tableView setTableFooterView:[[UIView alloc] initWithFrame:CGRectZero]];
    // 清除分割线
    self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
    
    // 请求数据
    [self initValue];
}

/**
 *  请求数据
 */
- (void)initValue {
    // 标题数组假数据
    _titles = @[@"朋友", @"同学", @"家人", @"同事"];
    
    // 初始化所有section都是折叠状态
    _isOpen = [[NSMutableArray alloc] initWithCapacity:_titles.count];
    for (int i = 0; i<_titles.count; i++) {
        [_isOpen addObject:@NO];
    }
}

#pragma mark - 组设置
/**
 *  多少组
 */
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return _titles.count;
}
/**
 *  section header的高度
 */
- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {
    return sectionHeaderH;
}
/**
 *  section header的视图
 */
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
    
    // section头部视图
    SectionHeaderView *sectionHeader = [[SectionHeaderView alloc] initWithFrame:CGRectMake(0, 0, ApplicationW, sectionHeaderH)];
    // 标题
    [sectionHeader setTitle:[_titles objectAtIndex:section] forState:UIControlStateNormal];
    // 为headerview打上tag
    [sectionHeader setTag:section];
    // 代理
    sectionHeader.delegate = self;
    
    return sectionHeader;
}
#pragma mark SectionHeader实现代理
/**
 *  实现代理,sectionheader 点击
 */
- (void)sectionDidClicked:(SectionHeaderView *)sender {
    
    // 取反状态
    BOOL reverse = ![_isOpen[sender.tag] boolValue];
    _isOpen[sender.tag] = [NSNumber numberWithBool:reverse];
    
    /***  这里刷新后section的header也会被刷新,导致指示箭头又恢复到旋转之前的状态,待解决  ***/
    // 刷新点击的分区(展开或折叠)
    [self.tableView reloadSections:[[NSIndexSet alloc] initWithIndex:sender.tag] withRowAnimation:UITableViewRowAnimationNone];
}

#pragma mark - 组内行设置
/**
 *  每组多少行
 */
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if ([_isOpen[section] boolValue]) {
        return 5; // 具体应该返回该分组好友的个数
    }else {
        return 0;
    }
}
/**
 *  cell
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    
    static NSString *identifier = @"identifier";
    // 具体可以自制cell组件
    AccountCell *cell = [[AccountCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
    // 头像,具体应该从好友数据中取
    [cell.avatar setImage:[UIImage imageNamed:@"male"]];
    // 昵称,具体应该从好友数据中取
    cell.name.text = @"夏明";
    //cell颜色
    //cell.backgroundColor = RGBColor(200, 200, 200);
    
    return cell;
}

@end

组件的调用

组件的使用很简单,一句话加入,引入TavleViewController类,实例化为一个子viewcontroller即可。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 显示折叠视图
    FolderTableViewController *foldableVC = [[FolderTableViewController alloc]init];
    [self addChildViewController:foldableVC];
    [self.view addSubview:foldableVC.view];
    
}

这里写图片描述

存在的问题(待解决)

存在的一个问题是,点击分组按钮时,刷新的是整个section,包括头部的header按钮,因此虽然头部按钮点击后箭头旋转了,但由于马上被更新了换了新的头部按钮,导致箭头看上去没有反应,而TableView并没有只更新某个section的所有cell而不更新section的header的方法。暂时没有想到比较优雅的解决办法,探索中…orz

Demo下载(不想花积分的请回复邮箱) http://download.csdn.net/detail/cordova/9673964

OC中的糖衣语法

糖衣语法的定义

糖衣语法,又叫‘语法糖’、‘语法盐’等等,是由英国计算机科学家彼得·约翰·兰达(Peter J.Landin)发明的一个术语,指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

糖衣语法在各种语言中都有出现,最常用的就是数组的[ ]操作符的下标访问以及{ }操作符对数组的初始化,例如C语言中可以通过下标访问数组元素,这种类似[ ]和{ }操作符的符合程序员思维的简单表示方法就是所谓的糖衣语法:

/* C中的数组操作 */
int a[3] = {1,2,3};
int b = a[2];

[ ]和{ }在JSON数据格式中最常见,[ ]一般封装一个数组,{ }一般封装一个整体对象。另外在OC中用到语法糖的一个非常重要的类型是NSNumber,一个将基本数据类型封装起来的对象类型,基本数据类型像‘@3’这种表达就是NSNumber的语法糖,也推荐这种用法。

OC中的糖衣语法

糖衣语法在OC中又常叫做‘字面量’,主要用在NSString,NSNumber,NSArray,NSDictionary这些类中,使用字面量可以更清晰的看清数据的结构,而且大大减小了代码编写的复杂繁琐度,代码易读性更高。

OC中字面量的用法主要由以下几种情况,包括基本数据类型NSNumber、静态数组NSArray和字典NSDictionary、可变数组NSMultableArray和字典NSMultableDictionary。其中静态的数组和字典不能直接用[ ]操作符来通过下标访问元素或者通过键值访问元素,而可变长数组和字典可以;另外可变长数组和字典用字面量初始化时要进行multableCopy操作。

糖衣语法用法:

/** 糖衣语法【字典和数组元素中不可出现nil,会直接编译不通过】 **/
    
    /* 1.基本数据对象 */
    NSNumber *num_int = @1;
    NSNumber *num_float = @1.1f;
    NSNumber *num_bool = @YES;
    NSNumber *num_char = @'a';
    /* 类似还有:NSInteger, Double, Long, Short ... */
    
    /* 基本数据运算 */
    int operator_i = 3;
    float operator_f = 2.1f;
    NSNumber *expression = @(operator_i * operator_f);
    
    /* 2.静态数组、字典 */
    NSArray *array = @[@1, @2, @3];
    NSDictionary *dic = @{
                          @"KEY":@"VALUE",
                          @"KEY1":@"VALUE1"
                          };
    /* 访问但不可更新 */
    NSNumber *num = array[1];
    NSString *string = dic[@"KEY"];
    
    /* 3.可变数组、字典 */
    NSMutableArray *mulArray = [@[@"a", @"b", @"c"] mutableCopy];
    NSMutableDictionary *mulDic = [@{
                                     @"key": @"value",
                                     @"key1": @"value1"
                                     } mutableCopy];
    /* 可变数组元素的下标访问或键值访问以及元素更新 */
    NSString *mulstring = mulArray[1];
    mulArray[1] = @"d";
    NSString *dicstring = mulDic[@"key"];
    mulDic[@"key"] = @"value3";

原用法:

/** 对应的原语法【字典和数组元素中可以出现nil,nil会被过滤掉】 **/
    
    /* 1.基本数据对象 */
    NSNumber *num_int = [NSNumber numberWithInt:1];
    NSNumber *num_float = [NSNumber numberWithFloat:1.1f];
    NSNumber *num_bool = [NSNumber numberWithBool:YES];
    NSNumber *num_char = [NSNumber numberWithChar:'a'];
    // 类似还有:NSInteger, Double, Long, Short ...
    
    /* 2.静态数组、字典 */
    NSArray *array = [[NSArray alloc]initWithObjects:@1, @2, @3, nil];
    NSDictionary *dic = [[NSDictionary alloc] initWithObjectsAndKeys:
                         @"VALUE", @"KEY",
                         @"VALUE1", @"KEY1", nil];
    /* 访问(静态数组元素不可更新) */
    NSNumber *num = [array objectAtIndex:1];
    NSString *string = [dic objectForKey:@"KEY"];
    
    /* 3.可变数组、字典 */
    NSMutableArray *mulArray = [[NSMutableArray alloc]initWithObjects:@"a", @"b", @"c", nil];
    NSMutableDictionary *mulDic = [[NSMutableDictionary alloc]initWithObjectsAndKeys:
                                   @"value", @"key",
                                   @"value1", @"key1", nil];
    
    /* 访问和更新 */
    NSNumber *mulnum = [mulArray objectAtIndex:1];
    [mulArray setObject:@"d" atIndexedSubscript:1];
    NSString *mulstring = [mulDic objectForKey:@"KEY"];
    [mulDic setObject:@"value2" forKey:@"key"];

问题: OC的数组或字典中,添加nil对象会有什么问题?

数组或字典如果通过addObject函数添加nil会崩溃,但初始化时通过initWithObjects方法里面的nil会被编译器过滤去掉不会有影响。另外如果使用糖衣语法初始化数组或字典也不可以有nil,此时nil不会被过滤掉也会崩溃。

/* 1.糖衣语法 */
NSArray *array = @[@1, @2, @3, nil]; // 错误,不可有nil,会编译不通过:void*不是Objective-C对象
NSDictionary *dic = @{
                      @"KEY":@"VALUE",
                      @"KEY1":@"VALUE1",
                      @"KEY2":nil
                       }; // 语法就是错误的,编译不通过
                       
/* 2.原用法 */
NSMutableArray *mulArray = [[NSMutableArray alloc] initWithObjects:@1, @2, @3, nil]; // 正确,没毛病
NSMutableDictionary *mulDic = [[NSMutableDictionary alloc] initWithObjectsAndKeys:
                         @"VALUE", @"KEY",
                         @"VALUE1", @"KEY1", nil]; // 正确,没毛病
/* 下面添加nil都会编译警告,运行起来会崩溃 */
[mulArray addObject:nil];
[mulDic setObject:nil forKey:@"KEY2"];

问题: Objective-C中的可变和不可变类型是什么?

Objective-C中的mutable和immutable类型对应于动态空间分配和静态空间分配。最常见的例子是数组和字典。例如NSArray和NSMutableArray,前者为静态数组,初始化后长度固定,不可以再动态添加新元素改变数组长度;后者为动态数组,可以动态添加或者删除元素,动态申请新的空间或释放不需要的空间,伸缩数组长度。


问题: @[@"a",@"b"];该类型是?

  • 字符串对象
  • 字典对象
  • 数组对象 (right)
  • 集合对象

全面理解Objective-C中的Property属性

OC中的属性

属性(Property)是Objective-C语言的其中一个特性,它把类对象中的实例变量及其读写方法统一封装起来,是对传统C++中要重复为每个变量定义读写方法的一种封装优化,OC将这些实例变量封装为属性变量,系统可自动生成getter和setter读写方法,同时仍然允许开发者利用读写语义属性参数(readwrite等)、@synthesize和@dynamic关键词去选择性自定义读写方法或方法名。

回归传统C++类实例变量的定义形式

原本的类实例变量定义形式如下,类(Class)是实例变量和方法的集合,变量的定义可以通过public、private和protected等语义关键词来修饰限定变量的定义域,实现对变量的封装,OC中仍然保留这种定义方法,其中关键词改为:@public、@private、@protected以及@package等,在头文件中的变量默认是@protected,在.m文件中的变量默认是@private。

@interface Test : NSObject {

    @public    // 声明为共有变量
    NSString *_name;
        
    @private   // 限制为私有变量,.m实现文件中定义变量的默认类型
    NSString *_major;
        
    @protected // 限制为子类访问变量,头文件中定义变量的默认类型
    NSString *_accupation;
    
    @package   // 包内变量,只能本框架内使用
    NSString *_company;
        
}

这种传统定义形式的缺点:

1.每个变量都要手动编写getter和setter方法,当变量很多时类中会出现大量的这些读写方法的代码,同时这些读写方法的形式是相同的,因此会产生代码冗余。OC中属性变量的封装就是将这些方法的定义封装起来,减少大量形式重复的方法定义;

2.这种类变量定义的方式属于“硬编码”,即对象内部的变量定义和布局已经写死在了编译期,编译后不可再更改,否则会出错。因为上面所谓的硬编码,指的是类中的变量会被编译器定义为离对象初始指针地址的偏移量(offset),编译之后变量是通过地址偏移来寻找的,如果想要在类中插入新的变量,则必须要重新编译计算每个变量的偏移量。否则顺序被打乱会读取错误的变量。例如下面的例子,在编译好的对象中变量的前面插入新的变量:

这里写图片描述

插入后_occupation的偏移量变了,因为现在是第三个指针,这时候按照编译器的结果访问就会出错。

Property属性变量封装定义

存取方法和变量名的自动合成:

使用OC的property属性,编译器会自动按照OC的严格的存取函数命名规范自动生成对应的存取函数,通过存取函数就可以根据变量名访问对应的变量,通过“点语法”访问变量其实就是调用了变量的存取方法(编译器会将点语法转换成存取方法的调用),也就是说通过属性定义的变量名成了存取函数名。此外,还会自动生成对应的实例变量名,由于自定义的变量名跟获取函数名一样,为了区分,实际的变量名在前面加下划线,另外虽然默认是加下划线,但可以在实现文件中使用关键词@synthesize自定义实际的变量名。下面例子中使用property属性定义变量,编译器会自动生成对应的存取方法和加下划线的实例变量名,由于是编译期生成的方法所以编译之前看不到:

@interface Test : NSObject

    /* property属性声明变量,编译期到时会自动生成获取方法:name和设置方法:setName */
    @property NSString *name;

@end
    Test *test = [[Test alloc] init];
    /** 通过点语法访问变量,等效于调用自动生成的存取方法访问变量:**/
    /* 1.调用test的setter方法设置变量 */
    test.name = @"sam";
    /* 等效于: */
    [test setName:@"sam"];
    
    /* 2.调用test的getter获取方法访问变量 */
    NSString *s = test.name;
    /* 等效于: */
    NSString *s = [test name];
    
    /* 3.name成了存取方法的函数名,所以要想直接访问实例变量要使用自动生成的变量名,也就是_name */
    _name = @"albert";
    s = _name;

@synthesize自定义变量名(特殊使用场景):

如果在Test的实现文件中使用@synthesize关键字自定义实例变量名,那么就不可以通过_name默认变量名来直接访问变量了,而是要使用自定义的名字,但实际为了规范和约定,@synthesize自定义实例变量名的用法是不建议使用的(@synthesize的原始用法是和@property成对出现来自动合成指定属性变量的存取方法的):

@implementation Test

/* 自定义实例变量名 */
@synthesize name = theName;

@end

现在要直接访问实例变量(不使用存取函数)就要通过自定义的变量名了:

/* 通过自定义的变量名访问,此时_name已经不存在了 */
theName = @"albert";
s = theName;

【注意:】较旧版本@synthesize和@property是成对出现的,也就是说要手动使用@synthesize来合成相应的存取方法,否则不会自动合成(现在编译器默认会自动添加@synthesize自动合成存取方法)。

@synthesize name; // 旧版本手动指定要合成存取方法的变量

此时set方法名为:setName, 变量名和get方法名都为name,即name作为方法调用就是方法名,作为变量直接取就是变量名:

name = @"Sam"; // name为变量名
NSString *oldName = [self name]; // name为get方法名

@dynamic禁止存取方法自动合成:

@dynamic关键字是用来明确告诉编译器禁止自动合成属性变量的存取方法和加下划线的默认变量名。默认情况如果不用@dynamic关键字,编译器就会在编译器自动合成那些没有定义的存取方法,而那些程序员已经定义了的存取方法则不会再去合成,即程序员定义的存取方法优先级高。例如,如果此时程序员自定义了setter方法,那么编译器就会只自动合成getter方法,而不会再去合成已经定义了的setter方法。

@implementation Test

/* 禁止编译器自动生成存取方法 */
@dynamic name;

@end

此时,如果代码中依旧使用点方法,或者通过存取函数调用来访问name,编译之前并没有异常,但编译之后由于在编译期编译器并没有自动合成存取方法,运行起来时会在存取方法调用的位置处程序崩溃,因为调用了不存在的方法。

不同属性特质修饰词的限制

通过在@property后的括号内添加属性特质参数,也可以影响存取方法的生成:

@interface Test : NSObject

/* 括号内添加属性特质进行限制 */
@property(nonatomic, readwrite, copy) NSString *name;

@end

属性参数主要可以分为三类:

  • 原子性: atomic,nonatomic
  • 读写语义:readwrite,readonly,getter,setter
  • 内存管理语义:assign,weak,unsafe_unretained,retain,strong,copy

其中最重要的是内存管理语义,要理解内存管理语义的作用和用法,首先要理解内存管理中的引用计数原理,也就是要理解OC的内存管理机制,属性参数的内存管理语义是OC中协助管理内存的很重要一部分。各种属性参数的含义和区别如下:

  • atomic、nonatomic: 原子性和非原子性。原子性是数据库原理里面的一个概念,ACID中的第一个。在多线程中同一个变量可能被多个线程访问甚至更改造成数据污染,因此为了安全,OC中默认是atomic,会对setter方法加锁,相应的也会付出维护原子性(数据加锁解锁等)的系统资源代价。应用中如果不是特殊情况(多线程间的通讯编程),一般还是用nonatomic来修饰变量的,不会对setter方法加锁,以提高多线程并发访问时的性能。
  • readonly、readwrite: readonly表示变量只读,也就是它修饰的变量只有get方法没有set方法;readwrite就是既有get方法也有set方法了,可读亦可写;
  • getter = < gettername >, setter = < settername >: 可以选择性的在括号里直接指定存取方法的方法名,例如:
	/* 更改默认的获取方法name为getName */
	@property(nonatomic, getter=getName, copy) NSString *name;
	
	/* 之后要调用获取方法应使用上面指定的 */
	s = [test getName];
  • assign: 直接简单赋值,不会增加对象的引用计数,用于修饰非OC类型,主要指基础数据类型(例如NSInteger)和C数据类型(int, float, double, char等),或修饰对指针的弱引用;
  • weak: 修饰弱引用,不增加引用对象的引用计数,主要可以用于避免循环引用,和strong/retain对应,功能上和assign一样简单,但不同的是用weak修饰的对象消失后会自动将指针置nil,防止出现‘悬挂指针’
  • unsafe_unretained:这种修饰方式不常用,通过名字看出它是不安全的,为什么这么说呢?首先它和weak类似都是自己创建并持有的对象之后却不会继续被自己持有(引用计数没有+1,引用计数为0的时候会被自动释放,尽管unsafe_unretained和weak修饰的指针还指向那个对象)。不同的是虽然在ARC中由编译器来自动管理内存,但unsafe_unretained修饰的变量并不会被编译器进行内存管理,也就是说既不是强引用也不是弱引用,生成的对象立刻就被释放掉了,也就是出现了所谓的‘悬挂指针’,所以不安全。
  • retain: 常用于引用类型,是为了持有对象,声明强引用,将指针本来指向的旧的引用对象释放掉,然后将指针指向新的引用对象,同时将新对象的索引计数加1;
  • strong: 原理和retain类似,只不过在使用ARC自动引用计数时,用strong代替retain;
  • copy: 建立一个和新对象内容相同且索引计数为1的对象,指针指向这个对象,然后释放指针之前指向的旧对象。NSString变量一般都用copy修饰,因为字符串常用于直接复制,而不是去引用某个字符串;

【补充:】除了在属性变量前面加修饰词,开发中还会用到一些所有权修饰符,例如: __strong和 __weak。所有权修饰符和上面的修饰符有着对应关系,使用的目的和原理是一样的,可结合理解记忆,他们的对应关系如下:

  • __strong修饰符对应于上面的strongretain还有copy,强引用来持有对象,它和C++中的智能指针std::shared_ptr类似,也是通过引用计数来持有实例对象;
  • __weak修饰符对应于上面的weak,同样它和C++中的智能指针std::weak_ptr类似,也是用于防止循环引用问题;
  • __unsafe __unretained修饰符对应于上面的assignunsafe_unretained,创建但不持有对象,可能导致指针悬挂。

相关问题

问题: OC中的属性和实例变量有哪些区别?

@interface Test : NSObject {
    /* 实例变量 */
    @private
    NSString *major;
    @public
    int age;
}

/* 属性变量 */
@property (nonatomic, copy) NSString *name;

@end

首先OC中的属性主要是对传统实例变量的封装,类对象有一个属性列表用来存放类的所有属性,属性和实例变量的区别主要有以下几个方面:

  • 实例变量的存放采用硬编码,编译后写死,根据离起始地址的偏移量来访问变量,不可再插入新变量,而属性可以在运行时动态添加删除;
  • 实例变量可以通过@private、@public和@protected等修饰词来定义变量的作用域,限制变量的访问权限,而属性不可以。从设计角度,属性主要是用来和外部类进行访问交互的,实例变量主要用于类内部使用;
  • 属性可以通过三类属性特质分别来帮助内存管理、多线程管理和读写控制,可以让编译器自动合成存取方法,而不用重复为每一个实例变量手写存取方法造成代码臃肿;

问题: 什么时候使用‘weak’关键字以及‘assign’和‘weak’的不同?

使用‘weak’关键字的几种情况:

  • ARC中为了避免出现循环引用,会让相互引用的对象中其中一个使用‘weak’弱引用;
  • 自定义的IBOutlet控件属性一般也是用weak;

‘assign’和‘weak’的区别主要是‘weak’修饰的指针变量在所指的对象释放时会自动将变量指针置nil,防止指针悬挂,而‘assign’不可以,‘assign’主要用于修饰简单纯量类型,进行简单赋值; ‘weak’只能用于修饰OC对象,而assign还可以用于修饰非OC对象。 ***

问题: atomic原子性属性和nonatomic非原子性属性有什么不同?默认的是哪一个?

atomic原子属性修饰的变量setter方法会加锁,防止多线程环境下被多个线程同时访问造成数据污染,但会浪费资源;而nonatomic非原子性属性修饰的变量setter方法不会被加锁。为了安全,默认的是atomic原子属性的。 ***

问题: ARC下,不显式指定任何属性关键字时,默认的关键字都有哪些?

默认的属性关键字分两种情况:一种是基本数据类型,一种是OC普通对象。不管哪种情况默认都有atomic原子属性和readwrite可读写属性,区别是基本数据类型默认是有个assign属性关键字,而OC对象对应的默认有个strong属性关键字。

  • 基本数据类型的默认关键字有:atomic,readwrite,assign

  • 普通OC对象的默认关键字有:atomic,readwrite,strong


问题: 什么是“强引用”和“弱引用”?为什么他们很重要以及它们是怎样帮助控制内存管理和避免内存泄漏的?

默认的指向对象的指针变量都是strong强引用,当两个或多个对象互相强引用的时候就可能出现循环引用的情况,也就是引用成了一个环状。例如在ARC自动引用计数机制下循环引用中的所有对象将永远得不到释放销毁而导致内存泄漏,因为引用循环使得里面的对象的引用计数至少为1(当应用中的所有其他对象都释放了对环内的这些对象的拥有权的时)。因此对象之间互相的强引用是要尽可能的避免的,使用weak修饰的弱引用就是为了打破循环引用从而避免内存泄漏的。


问题: @synthesize和@dynamic各表示什么,有什么不同?

@synthesize 修饰的属性默认情况下由系统自动合成setter和getter方法,除非开发者自己定义了这些方法;@synthesize经常用来更改属性的变量名,系统自动合成时默认变量名为_var,即在原变量名前加下划线。

@dynamic 用来明确禁止编译器自动合成属性存取方法和默认变量名_var,由程序员自己手动编写存取方法。

@synthesize和@dynamic,前者明确让编译器自动合成存取方法和默认变量名,而后者明确禁止编译器自动合成存取方法和默认变量名,因此两者语义冲突,不可同时使用。


问题: 类变量的@protected,@private,@public,@package声明各有什么含义?

前三个跟一般面向对象里面的继承封装概念相同:

@protected: 表示变量对子类可见,而对于其他类来说变量是私有的,不可访问;

@private: 表示变量完全私有化,只对本类可见,其子类也不可访问;

@public: 公开变量,表示变量对所有类都是开放可见的,都可以访问;

最后一个就是Objective-C中特有的一个修饰词了,一般在开发静态类库的时候会用到,意思是这个关键词修饰的变量对于framework包内部来说是@protected类型的,而对于包外来说是@priviate类型的,这样可以实现包内变量的封装,包内可以使用而包外不可用,防止使用该包的人看到这些变量。


问题: 这段代码有什么问题:

@implementation Person
- (void)setAge:(int)newAge {
self.age = newAge;
}
@end

self.age是调用self中变量age的setter方法,setter方法调用自身,即setter方法里面又嵌套调用set方法导致死循环。通过点语法访问变量时,变量为左值时调用的是setter方法,为右值时调用的是getter方法,例如下面点语法访问的变量作为右值时调用的是getter方法:

int age = self.age; // 调用了age变量的getter方法

问题: 在一个对象的方法里面:self.name = @”object”;和name = @”object”;有什么不同?

前者是调用setter方法赋值,后者是变量直接赋值。第一种情况的代码等效于:[self setName:@"object];。另外利用属性让编译器自动合成存取方法时变量名默认加下划线,因此第二种情况直接访问变量时通常为:_name = @"object"; ***

问题: __block 和 __weak 修饰符的区别?

__block: 可以用在ARC和MRC中,可以在MRC中避免循环引用问题但在ARC中不可以,可以修饰对象和基本数据类型,在block中可以被重新赋值。

__weak: 只能在ARC中使用,可以用于避免循环引用问题,只能修饰对象,不能修饰基本数据类型,不能在block中被重新赋值。


问题: 定义属性时,什么情况使用copy、assign、retain?

assign用于简单数据类型,如NSInteger,double,bool等等。retain和copy用于修饰OC对象,copy用于当a指向一个对象,b也想指向同样内容的对象但实际不是同一个对象的时候,如果用assign,a如果释放,再调用b会crash,如果用copy的方式,a和b各自有自己的内存,就可以解决这个问题。retain会使计数器加一,也可以解决assign的问题。 ***

问题: 分别写一个setter方法用于完成非ARC下的@property(nonatomicretain)NSString *name@property(nonatomiccopy)NSString *name

第一种情况retain是指针变量name对新赋值对象的强引用,相当于ARC下的strong,因此对name指针变量set新值时要先将新赋值对象的引用计数加1,然后将指针变量指向新赋值对象,类似于‘浅拷贝’。

首先在实现文件中合成属性变量:@synthesisze name;,然后两种情况下自定义setter方法如下,自定义了setter方法后编译器就不会再在编译期重复合成setter方法了:

/* retain */
- (void)setName:(NSString *)newName {
    if (name != newName) {
        [newName retain];  // 新对象引用计数加1
        [name release];    // 将指针变量原来的对象释放掉
        name = newName;    // 指针变量指向新对象
    }
}

第二种情况copy指的是对指针变量name赋值新对象时,是将新对象完全copy一份,将copy好的对象复制给指针变量,即指针指向的是临时copy出来的对象,而不是新赋值的那个对象,因此新赋值对象不需要引用计数加1,因为指针变量并没有指向持有它,类似于‘深拷贝’。

/* copy */
- (void)setName:(NSString *)newName {
    if (name != newName) {
	    id temp = [newName copy];  // 将新对象原样克隆一份
	    [name release];            // 将指针变量原来的对象释放掉
	    name = temp;               // 指针变量指向新对象的克隆体
    }
}

问题:如何仅仅通过属性特质的参数来实现公有的getter函数和私有的setter函数?

首先如果不考虑自动合成的功能,如果要手动写一个共有的getter函数那么我们先要在.h头文件中声明这个getter函数以暴露给外部调用,并在.m文件中进行实现,然后手动在.m文件中写一个私有的setter函数的实现即可,当然私有函数可以在.m的continue区域进行私有函数声明,但是没有必要,只要不在.h文件中声明暴露即可(C++中是要在.m文件最前面声明的,否则要考虑函数调用顺序,在函数实现之前无法调用)。这里以一个简单的Person类为例说明具体写法,手动实现的方法如下:

/* .h头文件区域 */
@interface Person : NSObject {
    @private
    NSString *name;
}

/* 声明公有的getter函数 */
- (NSString *)name;

@end
#import "Person.h"

/* continue 私有声明区域 */
@interface Person()

/* 在.m文件的continue区域声明私有setter方法,通常私有函数不需要声明,可以省略 */
- (void)setName:(NSString *)newName;

@end

/* implementation实现区域 */
@implementation Person

/**
 * 公有的getter函数实现
 */
- (NSString *)name {
    /* 注意这里直接返回实例变量,如果使用self.name相当于getter方法调用自身会造成死循环 */
    return name;
}

/**
 * 私有的setter函数实现
 */
- (void) setName:(NSString *)newName {
    /* 注意这里直接给实例变量赋值,如果使用self.name相当于setter方法调用自身会造成死循环 */
    name = newName;
}

@end

这样在类外部是可以调用getter方法的,但setter方法只能在本类内部调用,外部无法找到setter方法。

现在题目要求我们使用属性的读写语义也就是readwrite和readonly来让编译器自动合成上面的效果,如何实现呢?

实现方法是要在.h头文件和.m实现文件中定义属性变量两次,第一次在.h头文件中使用readonly读写语义让编译器自动合成公有的getter函数,第二次在.m文件中使用readwrite读写语义再让编译器自动合成私有的setter方法。写法如下:

/* .h头文件区域 */
@interface Person : NSObject

/* 使用readonly,让编译器只合成公有getter方法 */
@property (nonatomic, readonly, copy) NSString *name;

@end
/* continue 私有声明区域 */
@interface Person()

/* 让编译器再合成私有setter方法,其中readwrite可以省略,因为默认就是readwrite */
@property (nonatomic, readwrite, copy) NSString *name;
@end

/* implementation实现区域 */
@implementation Person

/**
 * 测试
 */
- (void)test {
    /* 下面两条语句等效,都是调用setter方法,但注意setter方法是私有的,只能在此处调用,在外部无法调用 */
    self.name = @"name";
    [self setName:@"name"];
}

@end

使用UIButton制作CheckBox复选框

思路:

UIKit框架中没有checkbox复选框的组件,但是UIButton组件有UIControlStateNormal和UIControlStateSelected两个状态,并且有selected属性纪录按钮是否被选中,因此可以继承按钮组件实现一个复选框的组件。

这里写图片描述

自定义UICheckBox类

这里定义了使用frame初始化复选框的函数和几种初始化复选框按钮图片的方法,初始化函数可以根据需要自己设计添加,也就是按照‘万能初始化’设计方式来设计完善这个复选框组件。

UICheckBox.h

//
//  UICheckBox.h
//  demo
//
//  Created by Jiangxh on 15/9/23.
//  Copyright © 2015年 Jiangxh. All rights reserved.
//
/************************************
 * 重写UIButton成复选框组件
 ************************************/
#import <UIKit/UIKit.h>

@interface UICheckBox : UIButton

/**
 *  复选框选中状态
 */
@property (nonatomic)BOOL isChecked;

/**
 *  用frame初始化checkbox按钮
 */
- (UICheckBox *)initWithFrame:(CGRect)frame;
/**
 *  设置未选中图片
 */
- (void)setNormalImage:(UIImage*)normalImage;
/**
 *  设置选中图片
 */
- (void)setSelectedImage:(UIImage*)selectedImage;
/**
 *  设置选中和未选中图片
 */
- (void)setImage:(UIImage*)normalImage andSelectedImage:(UIImage*)selectedImage;


/**
 *  用字符串设置未选中图片
 */
- (void)setNormalImageWithName:(NSString*)normalImageName;
/**
 *  用字符串设置选中图片
 */
- (void)setSelectedImageWithName:(NSString*)selectedImageName;
/**
 *  用字符串设置图片
 */
- (void)setImageWithName:(NSString*)normalImageName andSelectedName:(NSString*)selectedImageName;
/**
 * 按钮点击事件,点击后取反按钮状态
 */
-(void)checkboxClick;
@end

UICheckBox.m

//
//  UICheckBox.m
//  demo
//
//  Created by Jiangxh on 15/9/23.
//  Copyright © 2015年 Jiangxh. All rights reserved.
//
#import "UICheckBox.h"

@implementation UICheckBox

/**
 * 初始化checkbox按钮
 */
- (UICheckBox *)initWithFrame:(CGRect)frame {
    if ([super initWithFrame:frame]) {
        // 开始的时候设置复选框是未选中的
        self.selected = NO;
        _isChecked = NO;
        // 设置checkobx的监听事件
        [self addTarget:self action:@selector(checkboxClick) forControlEvents:UIControlEventTouchUpInside];
        return self;
    }
    return nil;
}

/**
 *  设置未选中图片
 */
- (void)setNormalImage:(UIImage*)normalImage {
    [self setImage:normalImage forState:UIControlStateNormal];
}
/**
 *  设置选中图片
 */
- (void)setSelectedImage:(UIImage*)selectedImage {
    [self setImage:selectedImage forState:UIControlStateSelected];
}
/**
 *  设置图片
 */
- (void)setImage:(UIImage*)normalImage andSelectedImage:(UIImage*)selectedImage {
    [self setNormalImage:normalImage];
    [self setSelectedImage:selectedImage];
}

/**
 *  用字符串设置未选中图片
 */
- (void)setNormalImageWithName:(NSString*)normalImageName {
    [self setNormalImage:[UIImage imageNamed:normalImageName]];
}
/**
 *  用字符串设置选中图片
 */
- (void)setSelectedImageWithName:(NSString*)selectedImageName {
    [self setSelectedImage:[UIImage imageNamed:selectedImageName]];
}
/**
 *  用字符串设置图片
 */
- (void)setImageWithName:(NSString*)normalImageName andSelectedName:(NSString*)selectedImageName {
    [self setNormalImageWithName:normalImageName];
    [self setSelectedImageWithName:selectedImageName];
}

/**
 *按钮点击事件,点击后取反按钮状态
 */
-(void)checkboxClick {
    // 取反复选框状态,通过取按钮的selected属性可以判断复选框当前有没有选中
    self.selected = !self.selected;
    _isChecked = self.selected;
    //后台打印调试
    if (self.selected) {
        NSLog(@"1");
    }else{
        NSLog(@"0");
    }
}

@end

组件使用测试

#define boxSize 50 //复选框尺寸

    // 定义复选框
    UICheckBox *checkbox = [[UICheckBox alloc] initWithFrame:CGRectMake(ApplicationW/2 - boxSize/2, ApplicationH/3, boxSize, boxSize)];
    // 设置复选框选中和未选中时的图片
    [checkbox setImageWithName:@"checkbox_off" andSelectedName:@"checkbox_on"];
    // 添加到当前视图
    [self.view addSubview:checkbox];

一步步学OpenGL(20) -《点光源》

教程 20

点光源

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial20/tutorial20.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

之前已经学习了三个基本的光照模型(环境光,漫射光和镜面反射光),这三种模型都是基于平行光的。平行光只是通过一个向量来表示,没有光源起点,因此它不会随着距离的增大而衰减(实际上没有起点根本无法定义光源和某个物体的距离)。现在我们再来看点光源类型,它有光源起点而且有衰减效果,距离光源越远光线越弱。点光源的经典例子是灯泡,灯泡在屋子里可能效果不明显,但是拿到室外就会明显看出它的衰减效果了。注意之前平行光的方向是恒定的,但点光源光线的方向是变化的,四处扩散。点光源想各个方向均匀照射,因此点光源的方向要通过计算物体到点光源之间的向量得到,这就是为什么要定义点光源的起点而不是它的方向。

点光源光线慢慢变淡的的想象叫做‘衰减’。真实光线的衰减是按照平方反比定律的,也就是说光线的强度和离光源的距离的平方成反比。数学原理如下图中的公式:

http://ogldev.atspace.co.uk/www/tutorial20/inverse_square_law.png

但3D图形中这个公式计算的结果看上去效果并不好。例如:当距离很近时,光的强度接近无穷大了。另外,开发者除了通过设置光的起始强度外无法控制点光源的亮度,这样就太受限制了,因此我们添加了几个新的因素到公式中使对其的控制更加灵活:

http://ogldev.atspace.co.uk/www/tutorial20/attenuation.png

我们在分母上添加了三个光衰减的参数因子,一个常量参数,一个线性参数和一个指数参数。当将常量参数和线性参数设置为零且指数参数设置为1时,就和实际的物理公式是对应的了,也就是这个特殊情况下在物理上是准确的。当设置常量因子参数为1时,调节另外两个参数整体上就有比较好的衰减变化效果了。常量参数的设置是要保证当距离为0时光照强度达到最大(这个要在程序内进行配置),然后随着距离的增大光照强度要慢慢减弱,因分母在慢慢变大。控制好线性参数因子和指数参数因子的变化,就可以实现想要的衰减效果,线性参数主要用于实现缓慢的衰减效果而指数因子可以控制光强度的迅速衰减。

现在总结计算点光源需要的步骤:

  • 计算和平行光一样的环境光;
  • 计算一个从像素点(世界空间中的)到点光源的向量作为光线的方向。利用这个光线方向就可以计算和平行光一样的漫射光以及镜面反射光了;
  • 计算像素点到点光源的距离用来计算最终的光线强度衰减值;
  • 将三种光叠加在一起,计算得到最终的点光源颜色,通过点光源的衰减性三种光看上去也可以被分离开了。

源代码详解

(lighting_technique.h:24)


struct BaseLight
{
    Vector3f Color;
    float AmbientIntensity;
    float DiffuseIntensity;
};
.
.
.
struct PointLight : public BaseLight
{
    Vector3f Position;

    struct
    {
        float Constant;
        float Linear;
        float Exp;
    } Attenuation;
}

平行光虽然和点光源不一样,但它们仍然有很多共同之处,它们共同的部分都放到了BaseLight结构体中,而点光源和平行光的结构体则继承自BaseLight。平行光额外添加了方向属性到它的类中,而点光源则添加了世界坐标系中的位置变量和那三个衰减参数因子。

(lighting_technique.h:81)

void SetPointLights(unsigned int NumLights, const PointLight* pLights);

这个教程除了展示如何实现点光源,还展示怎样使用多光源。通常只存在一个平行光光源,也就是太阳光,另外可能还会有一些点光源(屋子里的灯泡,地牢里的火把等等)。这个函数参数有一个点光源数据结构的数组和数组的长度,使用结构体的值来更新shader。

(lighting_technique.h:103)

struct {
    GLuint Color;
    GLuint AmbientIntensity;
    GLuint DiffuseIntensity;
    GLuint Position;
    struct
    {
        GLuint Constant;
        GLuint Linear;
        GLuint Exp;
    } Atten;
} m_pointLightsLocation[MAX_POINT_LIGHTS];

为了支持多个点光源,shader需要包含一个和点光源结构体(只在GLSL中)内容一样的结构体数组。主要有两种方法来更新shader中的结构体数组:

  • 可以获取每个数组元素中每个结构字段的位置(例如,一个数组如果有五个结构体,每个结构体四个字段,那就需要20个‘位置一致变量’),然后单独设置每个元素中每个字段的值。

  • 也可以只获取数组第一个元素每个字段的位置,然后用一个GL函数来保存元素中每个字段的属性类型。例如,数组元素也就是一个结构体的第一个字段是一个float变量,第二个是一个integer变量,就可以在一次回调中使用一个float数组遍历设置数组中每个结构体第一个字段的值,然后在第二次回调中使用一个int数组来设置每个结构体的第二个值。

第一种方法由于要维护大量的位置一致变量因此很浪费资源,但是会更加灵活,因为你可以通过位置一致变量访问更新数组中的任何一个元素,不需要像第二种方法那样先要转换输入的数据。

第二种方法不需要管理那么多的位置一致变量,但是如果想要同时更新数组中的几个元素的话,同时用户传入的又是一个结果体数组(像SetPointLights()),你就要先将这个结构体数组转换成多个字段的数组结构,因为结构体中每个位置的字段数据都要使用一个同类型的数组来更新。当使用结构体数组时,在数组中两个连续元素(结构体)中的同一个字段之间存在内存间隔(被其他字段间隔开了,我们是想要同一个字段的连续字段数组),需要将它们收集到它们自己的同类型数组中。本教程中,我们将使用第一种方法。最好两个都实现一下,看你觉得哪一个方法更好用。

MAX_POINT_LIGHTS是一个常量,用于限制可以使用的点光源的最大数量,并且必须和着色器中的相应值同步一致。默认值为2,当你增加应用中光的数量,随着光源的增加会发现性能越来越差。这个问题可以使用一种称为“延迟着色”的技术来优化解决,这个后面再探讨。

(lighting.fs:46)

vec4 CalcLightInternal(BaseLight Light, vec3 LightDirection, vec3 Normal)
{
    vec4 AmbientColor = vec4(Light.Color, 1.0f) * Light.AmbientIntensity;
    float DiffuseFactor = dot(Normal, -LightDirection);

    vec4 DiffuseColor = vec4(0, 0, 0, 0);
    vec4 SpecularColor = vec4(0, 0, 0, 0);

    if (DiffuseFactor > 0) {
        DiffuseColor = vec4(Light.Color * Light.DiffuseIntensity * DiffuseFactor, 1.0f);
        vec3 VertexToEye = normalize(gEyeWorldPos - WorldPos0);
        vec3 LightReflect = normalize(reflect(LightDirection, Normal));
        float SpecularFactor = dot(VertexToEye, LightReflect);
        if (SpecularFactor > 0) {
            SpecularFactor = pow(SpecularFactor, gSpecularPower);
            SpecularColor = vec4(Light.Color * gMatSpecularIntensity * SpecularFactor, 1.0f);
        }
    }

    return (AmbientColor + DiffuseColor + SpecularColor);
}

这里在平行光和点光源之间实现很多着色器代码的共享就不算什么新技术了。大多数算法是相同的。不同的是,我们只需要考虑点光源的衰减因素。 此外,针对平行光,光的方向是由应用提供的,而对点光源,需要计算每个像素的光的方向。

上面的函数封装了两种光类型之间的共用部分。 BaseLight结构体包含光强度和颜色。LightDirection是额外单独提供的,原因上面刚刚已经提到。 另外还提供了顶点法线,因为我们在进入片段着色器时要对其进行一次单位化处理,然后在每次调用此函数时使用它。

(lighting.fs:70)

vec4 CalcDirectionalLight(vec3 Normal)
{
    return CalcLightInternal(gDirectionalLight.Base, gDirectionalLight.Direction, Normal);
}

有了公共的封装函数,定义函数简单的包装调用一下就可以计算出平行光了,参数多数来自全局变量。

(lighting.fs:75)

vec4 CalcPointLight(int Index, vec3 Normal)
{
    vec3 LightDirection = WorldPos0 - gPointLights[Index].Position;
    float Distance = length(LightDirection);
    LightDirection = normalize(LightDirection);

    vec4 Color = CalcLightInternal(gPointLights[Index].Base, LightDirection, Normal);
    float Attenuation = gPointLights[Index].Atten.Constant +
                        gPointLights[Index].Atten.Linear * Distance +
                        gPointLights[Index].Atten.Exp * Distance * Distance;

    return Color / Attenuation;
}

计算点光比定向光要复杂一点。每个点光源的配置都要调用这个函数,因此它将光的索引作为参数,在全局点光源数组中找到对应的点光源。它根据光源位置(由应用程序在世界空间中提供)和由顶点着色器传递过来的顶点世界空间位置来计算光源方向向量。使用内置函数length()计算从点光源到每个像素的距离。 一旦我们有了这个距离,就可以对光的方向向量进行单位化处理。注意,CalcLightInternal()是需要一个单位化的光方向向量的,平行光的单位化由LightingTechnique类来负责。 我们使用CalcInternalLight()函数获得颜色值,并使用我们之前得到的距离来计算光的衰减。最终点光源的颜色是通过将颜色和衰减值相除计算得到的。

(lighting.fs:89)

void main()
{
    vec3 Normal = normalize(Normal0);
    vec4 TotalLight = CalcDirectionalLight(Normal);

    for (int i = 0 ; i < gNumPointLights ; i++) {
        TotalLight += CalcPointLight(i, Normal);
    }

    FragColor = texture2D(gSampler, TexCoord0.xy) * TotalLight;
}

有了前面的基础,片段着色器方面就变得非常简单了。简单地将顶点法线单位化,然后将所有类型光的效果叠加在一起,结果再乘以采样的颜色,就得到最终的像素颜色了。

(lighting_technique.cpp:279)

void LightingTechnique::SetPointLights(unsigned int NumLights, const PointLight* pLights)
{
    glUniform1i(m_numPointLightsLocation, NumLights);

    for (unsigned int i = 0 ; i < NumLights ; i++) {
        glUniform3f(m_pointLightsLocation[i].Color, pLights[i].Color.x, pLights[i].Color.y, pLights[i].Color.z);
        glUniform1f(m_pointLightsLocation[i].AmbientIntensity, pLights[i].AmbientIntensity);
        glUniform1f(m_pointLightsLocation[i].DiffuseIntensity, pLights[i].DiffuseIntensity);
        glUniform3f(m_pointLightsLocation[i].Position, pLights[i].Position.x, pLights[i].Position.y, pLights[i].Position.z);
        glUniform1f(m_pointLightsLocation[i].Atten.Constant, pLights[i].Attenuation.Constant);
        glUniform1f(m_pointLightsLocation[i].Atten.Linear, pLights[i].Attenuation.Linear);
        glUniform1f(m_pointLightsLocation[i].Atten.Exp, pLights[i].Attenuation.Exp);
    }
}

此函数通过迭代遍历数组元素并依次传递每个元素的属性值,然后使用点光源的值更新着色器。 这是前面所说的“方法1”。

本教程的Demo显示两个点光源在一个场景区域中互相追逐。一个光源基于余弦函数,而另一个光源基于正弦函数。该场景区域是由两个三角形组成的非常简单的四边形平面,法线是一个垂直的向量。

UITableView的重用机制与加载优化

UITableView可以说是UIKit中最重要的一个组件,用来展示数据列表,还可以灵活使用进行页面的布局。UITableView的使用遵循MVC模式,数据模型(NSObject)、视图(UIView)和控制器(UITableViewController)分离。UITableView继承自UIScrollView,可上下滑动,可以作为跟视图也可以作为子视图组件。

问题: UITableViewController中,创建UITableViewCell时,initWithSytle:resuseIdentifier中,reuseIdentifier有什么用?简述UITableViewCell的复用原理。

reuseIdentifier顾名思义是一个复用标识符,是一个自定义的独一无二的字符串,用来唯一地标记某种重复样式的可复用UITableViewCell,系统是通过reuseIdentifier来确定已经创建了的指定样式的cell来进行复用,iOS中表格的cell通过复用来提高加载效率,因为多数情况下表格中的cell样式都是重复的,只是数据模型不同而已,因此系统可以在保证创建足够数量的cell铺满屏幕的前提下,通过保存并重复使用已经创建的cell来提高加载效率和优化内存,避免不停地创建和销毁cell元素。

UITableViewCell的复用原理其实很简单,可以通过下面一个简单的例子来理解:

首先在开发中我们在UITableViewController类中写cell复用代码的最基本模板会像下面这样:

/**
 * 可复用cell制作
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    /* 定义cell重用的静态标志符 */
    static NSString *cell_id = @"cell_id_demo";
    /* 优先使用可复用的cell */
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cell_id];
    /* 如果要复用的cell还没有创建,则创建一个供之后复用 */
    if (cell == nil) {
        /* 新创建cell并使用cell_id复用符标记 */
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cell_id];
    }
    /* 配置cell数据 */
    cell.textLabel.text = [NSString stringWithFormat:@"Cell%i", countNumber];
    /* 其他cell设置... */
    return cell;
}

代码这样写的原因是通过调用当前tableView的dequeueReusableCellWithIdentifier方法看指定的reuseIdentifier是否有可以重复使用的了,如果有则会返回可复用的cell,cell就绪之后便可以开始更新cell的数据;如果还不可复用,则返回nil,然后会进入后面的if语句,此时创建新的cell并对其设置cell样式标记reuseIdentifier。注意上面的if语句并不是只要执行一次创建一次新的cell就完成任务,然后之后全部重复利用新创建的那一个cell,这是对cell复用机制的误解。事实是要创建足够数量的可覆盖整个tableView的可复用cell之后才会开始复用之前的cell(UITableView中有一个visiableCells数组保存当前屏幕可见的cell,还有一个reusableTableCells数组用来保存那些可复用的cell),这个我们用下面的测试来验证。

如何简洁清楚的展示UITableViewCell的复用机制呢?这里的方法是创建最基本的文本cell,并创建一个cell创建计数器,每次新创建cell计数器加1并显示在cell上,如果是复用的cell则会显示是复用的哪一个cell,测试代码如下:

/**
 * 分区个数设置为1
 */
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return 1;
}

/**
 * 创建20个cell,保证覆盖并超出整个tableView
 */
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return 20;
}

/**
 * cell复用机制测试
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    /* 定义cell重用的静态标志符 */
    static NSString *cell_id = @"cell_id_demo";
    /* 计数用 */
    static int countNumber = 1;
    /* 优先使用可复用的cell */
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cell_id];
    /* 如果要复用的cell还没有创建,则创建一个供之后复用 */
    if (cell == nil) {
        /* 新创建cell并使用cell_id复用符标记 */
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cell_id];
        /* 计数器标记新创建的cell */
        cell.textLabel.text = [NSString stringWithFormat:@"Cell%i", countNumber];
        /* 计数器递增 */
        countNumber++;
    }
    return cell;
}

运行在iPhone5S设备上(UITableViewController作为跟控制器,tableView覆盖整个屏幕),20个cell显示结果依次为:

Cell1、Cell2、Cell3、Cell4、Cell5、Cell6、Cell7、Cell8、Cell9、Cell10、Cell11、Cell12、Cell13、Cell14、Cell1、Cell2、Cell3、Cell4、Cell5、Cell6

这里写图片描述

这里写图片描述

可以看出一共创建了14个cell,其中整个屏幕可显示13个cell,系统多创建一个的原因是保证在表格滑动显示半个cell时仍然能覆盖整个tableView。之后的6个cell就是复用了开始创建的那6个cell了。这样UITableViewCell复用的基本机制就很清楚了,另外还会有reloadData或者reloadRowsAtIndex等刷新表格数据的情况,可能会伴随新的cell创建和可复用cell的更新,但也是建立在基本复用机制的基础之上的。

相关问题:UITableView中cell的复用是由几个数组实现的:

  • 1

  • 2(right)

  • 3

  • 3或4

UITableView中cell的复用是由2个数组实现的,一个是visiableCells数组,保存当前屏幕可见的cell,还有一个reusableTableCells数组用来保存那些可复用的cell。


问题: 能否在一个视图控制器中嵌入两个tableview控制器?

可以,相当于视图以及视图控制器的嵌套,视图可以添加子视图,视图控制器也可以添加子控制器。这么问应该是因为这种情况有时会用到而且很重要,因为有一点容易被忽视,就是将子视图添加到了父视图却忘记将对应的控制器作为子控制器添加到父控制器,导致子视图能显示但是不能响应(没有对接好控制器)。例如在当前视图上放一个小尺寸的表格组件,也就是在UIViewController上添加一个UITableViewController子控制器及其子view:

    /* 假设有三个视图控制器,一个作为父控制器,两个作为子控制器 */
    UIViewController *superVC = [[UIViewController alloc]init];
    UITableViewController *subVC1 = [[UITableViewController alloc]init];
    UITableViewController *subVC2 = [[UITableViewController alloc]init];
    
    /* 将子视图控制器添加到父视图控制器(要注意调整子视图的尺寸和位置合理显示,这里忽略) */
    [superVC.view addSubview:subVC1.view];
    [superVC addChildViewController:subVC1];
    
    [superVC.view addSubview:subVC2.view];
    [superVC addChildViewController:subVC2];
    
    /* 子视图控制器的移除有对称的方法,但只能是子视图控制器主动从父视图控制器中移除 */
    [subVC1.view removeFromSuperview];
    [subVC1 removeFromParentViewController];
    
    [subVC2.view removeFromSuperview];
    [subVC2 removeFromParentViewController];
    

此外要注意和presentViewController函数添加子视图控制器的区别,上面手动添加子视图控制器是可以自由调整子视图的frame的(包括子视图位置和尺寸),而presentViewController是用于页面切换,切换后的子页面会覆盖整个屏幕而不可以自由调整子页面位置和尺寸,对称的子视图控制器移除方法为dismissViewControllerAnimated:

    /* 显示子视图控制器,completion后的代码块如果不为空添加结束后会触发 */
    [[parentVC presentViewController:childVC animated:NO completion:nil];
    /* 移除子视图控制器,completion后的代码块如果不为空添加结束后会触发 */
    [childVC dismissViewControllerAnimated:NO completion:nil];

问题: 一个tableView是否可以关联两个不同的datasource数据源?如何处理?

多个数据源是完全可以的,关键是如何关联,问题的重点是如何处理,因为将数据源(Model)和tableview视图(View)的对接工作是程序员完成的,因此数据源的多少没有根本影响。处理上可以分开依次对接,也可以通过数据的集合操作先将数据整理合并成一个数据源然后对接。

例如:一个表格中的每个cell显示的是一个人的基本信息,为了简单这里假设只有一个头像和一个姓名。假设有两个数据源,一个数据源是头像的url数组,一个是姓名的字符串数组,对接时完全可以分开在cell数据回调中对接,也可以将两个数组合并然后对接。

合并数据用到的数据模型:

@interface Model : NSObject

@property (nonatomic,copy) NSString *name;  // 姓名
@property (nonatomic,copy) NSString *url;   // 图片

@end

数据源缓冲器:

/* 数据源 */
@property (nonatomic, strong)NSArray *name_datasource;
@property (nonatomic, strong)NSArray *url_datasource;
@property (nonatomic, strong)NSMutableArray *datasource;

处理多数据源:

/**
 * 请求数据
 */
- (void)request {
    /* 姓名数据源 */
    _name_datasource = @[@"张三", @"李四", @"小明", @"小李"];
    _url_datasource = @[@"male", @"male", @"male", @"male"];
    
    /* 合并数据源 */
    for (int i; i<_name_datasource.count; i++) {
        Model *model = [[Model alloc]init];
        model.name = _name_datasource[i];
        model.url = _url_datasource[i];
        [_datasource addObject:model];
    }
}

数据对接:

/**
 *  cell数据回调
 */
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    
    static NSString *identifier = @"identifier";
    /* 自制cell组件 */
    AccountCell *cell = [[AccountCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier];
    
    /** 多数据源分开对接:**/
    /* 头像 */
    [cell.avatar setImage:[UIImage imageNamed:_url_datasource[indexPath.row]]];
    /* 姓名 */
    cell.name.text = _name_datasource[indexPath.row];
    
    /* 或者:*/
    
    /** 数据源合并后对接 **/
    /* 取出对应数据模型 */
    Model *model = _datasource[indexPath.row];
    /* 头像 */
    [cell.avatar setImage:[UIImage imageNamed:model.url]];
    /* 姓名 */
    cell.name.text = model.name;
    
    return cell;
}

问题:如何对UITableView的滚动加载进行优化,防止卡顿?

UITableView的滚动优化主要在于以下两个方面:

  • 减少cellForRowAtIndexPath代理中的计算量(cell的内容计算)
  • 减少heightForRowAtIndexPath代理中的计算量(cell的高度计算)

这里写图片描述

减少cellForRowAtIndexPath代理中的计算量

  • 首先要提前计算每个cell中需要的一些基本数据,代理调用的时候直接取出;
  • 图片要异步加载,加载完成后再根据cell内部UIImageView的引用设置图片;
  • 图片数量多时,图片的尺寸要跟据需要提前经过transform矩阵变换压缩好(直接设置图片的contentMode让其自行压缩仍然会影响滚动效率),必要的时候要准备好预览图和高清图,需要时再加载高清图。
  • 图片的‘懒加载’方法,即延迟加载,当滚动速度很快时避免频繁请求服务器数据。
  • 尽量手动Drawing视图提升流畅性,而不是直接子类化UITableViewCell,然后覆盖drawRect方法,因为cell中不是只有一个contentview。绘制cell不建议使用UIView,建议使用CALayer。原因要参考UIView和CALayer的区别和联系。

减少heightForRowAtIndexPath代理中的计算量

  • 由于每次TableView进行update更新都会对每一个cell调用heightForRowAtIndexPath代理取得最新的height,会大大增加计算时间。如果表格的所有cell高度都是固定的,那么去掉heightForRowAtIndexPath代理,直接设置TableView的rowHeight属性为固定的高度;
  • 如果高度不固定,应尽量将cell的高度数据计算好并储存起来,代理调用的时候直接取,即将height的计算时间复杂度降到O(1)。例如:在异步请求服务器数据时,提前将cell高度计算好并作为dataSource的一个数据存到数据库供随时取用。

表视图的相关类有哪些?(多选)

  • UITableView(right)
  • UITableViewController(right)
  • UITableViewDelegate
  • UITableViewDataSource

一步步学OpenGL(19) -《镜面反射光》

教程19

镜面反射光

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial19/tutorial19.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

我们在计算环境光的时候,光的强度是唯一的影响因素。然后处理漫射光的时候公式中加入了光的方向参数。镜面反射包含了上面所有的综合因素并且添加了一个新的元素:观察者的位置。镜面反射时光以一定角度照射到物体表面,同时会在法线的另一侧对称的角度上反射出去,如果观察者刚好在反射光线的路径上那么就会看到格外强烈的光线。

镜面反射最终的结果是物体在从某个角度看上去会十分明亮,而移动开后这个光亮又会消失。现实中好的镜面反射的例子是金属物体,这些物体有时候看上去由于太亮了导致看不到他本来的颜色而是直接照向你眼睛的白色的亮光。但这种属性在其他的一些材料上是没有的(比如:木头)。很多东西根本不会发光,不管光源从什么角度照射以及观察者在什么位置。所以,镜面反射光的存在更取决于反射物体的材料性质而不是光源本身。

现在看如何将观察者的位置加入到镜面反射光的计算当中,看下图:

image description

要注意五个因素:

‘I’ 是入射光

‘N’ 是表面法线

‘R’ 反射光,和入射光’I’关于法线对称,但方向相反

‘V’ 是从入射光和反射光交点处(入射点)到观察者眼睛的向量,表示观察者视线

‘α’ 反射光’R’和观察者视线’V’的夹角

我们将使用夹角’α’来对镜面反射光现象进行建模。有一点可以看出当观察者视线和反射光重合时(夹角为0),反射光的强度最大。观察者慢慢从反射光’R’移开时,夹角慢慢变大,而我们希望随着角度增大反射光要慢慢衰弱。明显,这里又要使用差积运算来计算夹角’α’的余弦值了,这个值将作为计算镜面反射光公式的反射参数。当’α’为0时余弦值为1,这是我们反射参数的最大值。随着夹角’α’增大余弦值慢慢减小,直到夹角达到90度时就彻底没有镜面反射的效果了。当然,夹角大于90度时余弦值为负,也没有任何反射效果,也就是观察者不在反射光的路径范围内。

我们要用到’R’和’V’来计算夹角’α’。’V’可以通过世界坐标系中观察者位置和光的入射点位置的差计算得到。camera已经在世界空间进行维护了,我们只需要将它的位置传给shader着色器。另外上面的图是经过简化了的模型,光在物体表面只有一个入射点(事实上不是,这里只是为了好分析)。事实上,整个三角形都被点亮了(假设它面向光源),因此我们要计算每一个像素的镜面反射效果(和漫反射光的计算一样)。我们必须要知道每个像素在世界空间的位置,这个不难:可以将顶点变换到世界空间,让光栅器对像素的世界空间位置进行插值并将结果传给片段着色器。事实上,这个和之前教程中对法线的处理操作是一样的。

最后是要使用’I’向量(由应用传给shader)来计算反射光线’R’。如下图:

image description

首先要强调向量没有起点的概念,所有方向相同且长度相同的向量都是同一个向量。因此,图中将入射光向量’I’复制到表面下面位置向量本身是不变的。目标是求向量’R’,根据向量的加法,’R’等于’I’+’V’。’I’是已知的,所以我们要求’V’。注意法线’N’的反向向量为’-N’,计算’I’和’-N’的点积可以得到’I’在’-N’上的投影,这也是’V’的模长度的一半。另外’V’和’N’的方向是相同的,所以只要用计算的那个投影长度乘以单位向量’N’再乘以2就是向量’V’了。用公式简单表示如下:

image

明白这个数学公式后可以说一个相关的知识点:GLSL提供了一个叫做’reflect’的内部函数就是做的上面这个计算。可以看下面这个函数在shader中的用法。这里得出计算镜面反射的最终公式:

image description

开始先是将光的颜色和物体表面的颜色相乘,这个和在计算环境光以及漫反射光时一样。得到的结果再和材料的镜面反射强度参数(’M’)相乘。如果材料没有反射性能,比如木头,那么镜面反射参数就为0,整个公式的结果也就为0了,而像金属这种发光材料镜面反射能力就会很强。之后再乘以光线和观察者视线夹角的余弦值,这也是最后一个调整镜面反射光强度的参数(‘镜面参数’或者叫做‘发光参数’)。镜面参数是用来增强加剧反射光区域边缘的强度的。下面的图片展示了镜面参数为1时的效果:

image description

下面的镜面参数为32:

image description

镜面反射能力也被认为是材料的一种属性,因此不同的物体会有不同的镜面反射能力值。

源代码详解

(lighting_technique.h:32)


class LightingTechnique : public Technique
{
public:
...
    void SetEyeWorldPos(const Vector3f& EyeWorldPos);
    void SetMatSpecularIntensity(float Intensity);
    void SetMatSpecularPower(float Power);

private:
...
    GLuint m_eyeWorldPosLocation;
    GLuint m_matSpecularIntensityLocation;
    GLuint m_matSpecularPowerLocation;
}

LightingTechnique类中有了三个新属性:眼睛(观察者)的位置、镜面反射强度和材料的镜面参数。这三个参数都是独立于光线本身的,因为当同一束光照到不同的材料上(比如:木头和金属)时会有不同的反射发光效果。目前对材料属性的的使用模型还是很局限的,同一个绘制回调的所有三角形会得到这些属性的一样的值。如果同一个模型的不同部分的三角形图元是不同的材料,这样就不合理了。在后面的教程中讲关于mesh网格的加载时我们会在一个模块中产生不同的镜面参数值并作为顶点缓冲器的一部分(而不是shader的一个参数),这样我们就可以在同一个绘制回调中使用不同的镜面光照参数来处理三角形图元。这里简单的使用一个shader参数就可以实现效果(当然可以尝试在顶点缓冲中添加不同的镜面强度参数然后在shader中获取来实现更复杂的镜面效果)。

(lighting.vs:12)


out vec3 WorldPos0;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoord0 = TexCoord;
    Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
    WorldPos0 = (gWorld * vec4(Position, 1.0)).xyz;
}

上面顶点着色器多了最后一行代码,世界变换矩阵(之前用来变换法线的那个世界变换矩阵)这里用来将顶点的世界坐标传给片段着色器。这里有一个技术点是使用两个不同的矩阵来变换本地坐标提供的同一个顶点位置,并将结果独立的传递给片段着色器。经过完整的变换(world-view-projection变换)后结果传递给系统变量’gl_Position’,然后GPU负责将它变换到屏幕空间坐标系并用来进行实际的光栅化操作。局部变换到世界空间的结果传给了一个用户自定义的属性,这个属性在光栅化阶段被进行了简单的插值,所以片段着色器中激活的每一个像素都会提供它自己的世界空间位置坐标。这种技术很普遍也很有用。


(lighting.fs:5)
in vec3 WorldPos0;
.
.
.
uniform vec3 gEyeWorldPos;
uniform float gMatSpecularIntensity;
uniform float gSpecularPower;

void main()
{
    vec4 AmbientColor = vec4(gDirectionalLight.Color * gDirectionalLight.AmbientIntensity, 1.0f);
    vec3 LightDirection = -gDirectionalLight.Direction;
    vec3 Normal = normalize(Normal0);

    float DiffuseFactor = dot(Normal, LightDirection);

    vec4 DiffuseColor = vec4(0, 0, 0, 0);
    vec4 SpecularColor = vec4(0, 0, 0, 0);

    if (DiffuseFactor > 0) {
        DiffuseColor = vec4(gDirectionalLight.Color, 1.0f) *
            gDirectionalLight.DiffuseIntensity *
            DiffuseFactor;

        vec3 VertexToEye = normalize(gEyeWorldPos - WorldPos0);
        vec3 LightReflect = normalize(reflect(gDirectionalLight.Direction, Normal));
        float SpecularFactor = dot(VertexToEye, LightReflect);
        if (SpecularFactor > 0) {
            SpecularFactor = pow(SpecularFactor, gSpecularPower);
            SpecularColor = vec4(gDirectionalLight.Color * gMatSpecularIntensity * SpecularFactor, 1.0f);
        }
    }

    FragColor = texture2D(gSampler, TexCoord0.xy) * (AmbientColor + DiffuseColor + SpecularColor);
}

片段着色器中的变化是多了三个新的一致性变量,用来存储计算镜面光线的一些属性(像眼睛的位置、镜面光强度和镜面反射参数)。环境光颜色的计算和前面两篇教程中的计算一样。然后创建漫射光和镜面光颜色向量并初始化为0,之后只有当光线和物体表面的角度小于90度时颜色值才不为零,这个要通过漫射光参数来检查(和在漫射光教程中说的一样)。

下一步要计算世界空间中从顶点位置到观察者位置的向量,这个通过观察者世界坐标和顶点的世界坐标相减计算得到,其中观察者的世界坐标是一个一致变量对于所有的像素点来说都一样。为了方便后面的点积操作这个向量要进行单位化。然后,使用内置的’reflect’函数就可以计算反射光向量了(当然也可以自行按照上面背景中介绍的手动计算)。’reflect’函数有两个参数:光线向量和物体表面法向量。注意这里使用的是最原始的射向物体表面的那个光源向量而不是用于漫射光参数计算的反向的光源向量(见上面图示)。然后计算镜面反射参数,也就是反射光和顶点到观察者那个向量的夹角余弦值(还是通过点积计算得到)。

镜面反射效果只有在那个夹角小于90度时才看得到,所以我们要先检查点积的结果是否大于0。最后一个镜面颜色值是通过将光源颜色和材料的镜面反射强度以及材料镜面反射参数相乘计算得到。我们将镜面颜色值添加到环境光和漫射光颜色中来制造光颜色的整体效果。最后和从纹理中取样的颜色相乘得到最终的像素颜色。

(tutorial19.cpp:134)


m_pEffect->SetEyeWorldPos(m_pGameCamera->GetPos());
m_pEffect->SetMatSpecularIntensity(1.0f);
m_pEffect->SetMatSpecularPower(32);

镜面反射光颜色的使用很简单。在渲染循环我们得到了camera的位置(在世界空间中已经维护好了)并将它传给了LightingTechnique类。这里还设置了镜面反射强度和镜面参数。剩下的就由着色器来处理了。

可以调整镜面反射的参数值以及光源的方向来看效果。当然为了找到可以看到镜面反射光效果的位置可能需要围着物体转一圈。

Objective-C中的多态性

问题:什么叫多态?

多态(Polymorphism),在面向对象语言中指的是同一个接口可以有多种不同的实现方式,OC中的多态则是不同对象对同一消息的不同响应方式,子类通过重写父类的方法来改变同一消息的实现,体现多态性。另外我们知道C++中的多态主要是通过virtual关键字(虚函数、抽象类等)来实现,具体来说指的是允许父类的指针指向子类对象,成为一个更泛化、容纳度更高的父类对象,这样父对象就可以根据实际是哪种子类对象来调用父类同一个接口的不同子类实现。

举个简单例子来展示OC的多态实现。假设有一个动物父类Animal,其下有两个子类,一个是Dog,一个是Cat,父类有一个统一接口:shout,表示动物的叫声,父类对接口有一个默认实现,子类各自有自己的接口实现,继承关系如下:

Animal父类:

// Animal.h
@interface Animal : NSObject

/**
 * 父类接口,动物叫声
 */
- (void)shout;

@end

// Animal.m
#import "Animal.h"

@implementation Animal

/**
 * 父类接口的默认实现,无语
 */
- (void)shout {
    NSLog(@"... ...");
}

@end

Dog子类:

// Dog.h
#import "Animal.h"

@interface Dog : Animal

/**
 * 重写父类接口,狗叫声
 */
- (void)shout;

@end

// Dog.m
#import "Dog.h"

@implementation Dog

/**
 * 重写父类接口,狗叫声
 */
- (void)shout {
    NSLog(@"汪汪汪,汪汪汪");
}

@end

Cat子类:

// Cat.h
#import "Animal.h"

@interface Cat : Animal

/**
 * 重写父类接口,猫叫声
 */
- (void)shout;

@end

// Cat.m
#import "Cat.h"

@implementation Cat

/**
 * 重写父类接口,猫叫声
 */
- (void)shout {
    NSLog(@"喵喵喵,喵喵喵");
}

@end

多态性测试:

    /* 1. 指向Animal父类对象的Animal父类指针 */
    Animal *p_animal4animal = [[Animal alloc] init];
    /* 2. 指向Dog子类对象的Animal父类指针 */
    Animal *p_animal4dog = [[Dog alloc] init];
    /* 3. 指向Cat子类对象的Animal父类指针 */
    Animal *p_animal4cat = [[Cat alloc] init];
    
    /* 向指向不同对象的父类指针发送相同的消息,期望得到各自不同的结果,实现多态 */
    [p_animal4animal shout]; // 打印结果:... ...
    [p_animal4dog shout];    // 打印结果:汪汪汪,汪汪汪
    [p_animal4cat shout];    // 打印结果:喵喵喵,喵喵喵

问题: Objective-C和Swift中有重载吗?

Swift中有重载,但Objective-C中基本不完全支持重载,事实上OC支持参数个数不同的函数重载。

重载、重写以及隐藏三者在编程语言中的定义

重载(overload):函数名相同,函数的参数列表不同(包括参数个数和参数类型),至于返回类型可同可不同。重载发生在同一个类的不同函数之间,是横向的。重载和多态性无关。

重写(override):指的是virtual函数的重写,用来体现多态性,指的是子类不想继承使用父类的方法,通过重写同一个函数的实现实现对父类中同一个函数的覆盖,因此又叫函数覆盖。重写的函数必须和父类一模一样,包括函数名、参数个数和类型以及返回值,只是重写了函数的实现。重写发生于父类和子类之间,是纵向的。

隐藏:OC中也没有隐藏,典型的C++中有,通过虚函数和父子类之间的函数重写进行区分,此处不再讨论。其中重载和重写是针对函数的,而隐藏除了函数还会针对成员变量。隐藏发生在父类和子类之间,隐藏指的是父类的同名函数或变量在子类中隐藏,其中只要函数同名就隐藏,不管参数相同与否。在子类中父类的同名函数或变量不可见,但在父类中依然存在。

Swift是基于C语言和OC语言优化后更加完善的新型语言,摆脱了C的兼容性限制,采用安全的编程模式并且增加了一些新的特性使编程更加有趣、友好,适应语言发展的趋势和期望。函数重载作为多态性的一个部分在Swift中是支持的,可能也是考虑到要弥补OC中不完全支持函数重载的这一缺陷。OC不完全支持重载,因为OC学习者应该会发现同一个类中不允许定义函数名相同且参数个数相同的两个函数,无论参数类型和返回值类型相同与否。但是说完全不支持也太绝对,因为OC中允许定义函数名相同但参数个数不同的两个函数,也就是说OC支持参数个数不同的函数重载。 例如,我们可以在一个类中定义两个参数个数不同的函数,调用时通过参数个数进行区分:

重载函数定义:

- (void)test:(int)one;
- (void)test:(int)one andTwo:(int)two;

重载函数实现:

- (void)test:(int)one {
    NSLog(@"one parameter!");
}

- (void)test:(int)one andTwo:(int)two {
    NSLog(@"two parameters!");
}

多态调用:

[self test:1];          // output:one parameter!
[self test:1 andTwo:2]; // output:two parameter!

可以看出OC可以通过参数个数实现函数重载,但是如果参数相同,无论参数和返回值类型相同与否都无法编译通过。下面的定义是无法通过xcode的编译的:

- (void)test:(int)one;
- (int)test:(float)one; // Duplicate declaration of method 'test'

问题:Object-C的类可以多重继承么?可以实现多个接口么?重写一个类的方式用继承好还是分类好?为什么?

Objective-C的类只支持单继承,不可以多重继承。可以利用protocol代理协议实现多个接口,通过实现多个接口完成类似C++的多重继承;在Objective-C中多态特性是通过protocol协议或者Category类别来实现的。protocol协议定义的接口函数可以被多个类实现,Category类别可以在不变动原类的情况下进行函数重写或者扩展。 一般情况用分类更好,因为用Category去重写类的方法,仅对本Category有效,不会影响到其他类与原有类的关系。


问题:Cocoa中有虚基类的概念么?怎么简洁的实现? Cocoa中没有虚基类的概念,虚基类是C++中为了解决多重继承二义性问题的,而OC中只有单继承,要实现类似C++中的多继承,可以通过protocal协议来简单实现,因为一个类可以实现多个协议,类似于Java中一个类可以实现多个接口。


问题:简单说一下重载、重写和隐藏的区别?

一步步学OpenGL(18) -《漫反射光》

教程18

漫射光

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial18/tutorial18.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

漫射光和环境光的主要不同是漫射光的特性依赖光线的方向,而环境光完全忽略光的方向。当只有环境光时整个场景是被均匀照亮的,而漫射光使物体朝向它的那一面比其他背向光的面要更亮。漫射光还增加了一个光强度的变化现象,光的强度大小还取决于光线和物体表面的角度,这个概念可以在下图看出:

http://ogldev.atspace.co.uk/www/tutorial18/light_angle.png

假想两边的光线的长度是一样的,唯一不同的是他们的方向。按照漫射光的模型,左边物体的表面要比右边的物体要亮,因为右边光线的入射角比左边的大很多。实际上左边的亮度是能达到的最大亮度了,因为它是垂直入射。

漫射光模型是建立在兰伯特余弦定律上的,光想的强度和观察者视线与物体表面法线夹角的余弦值成正比(夹角越大光强度越小)。注意这里略有变化,我么使用的是光线的方向而不是观察者视线(在镜面发射中用到)的方向。

为了计算光的强度我们要引入光线和物体表面法线(兰伯特定律中更加通用的概念叫做’directionaly proportional’)夹角的余弦值作为一个参数变量。看下面这幅图:

http://ogldev.atspace.co.uk/www/tutorial18/lambert_law.png

图中四条光线以不同的角度照到表面上(光线仅仅是方向不同),绿色的箭头是表面法向量,从表面垂直往外发出。光线A的强度是最大的,因为光线A和法线的夹角为0,余弦值为最大的1,也就是这个光线的强度(三个通道的三个0-1的值)和表面颜色相乘每个颜色通道都是乘以1,这是漫射光强度最大的情况了。光线B以一定角度(0-90之间)照射到表面,这个角度就是光线和法线的夹角,那么夹角的余弦值应该在0-1之间,表面颜色值最后要和这个角度的余弦值相乘,那么得到的光的强度一定是比光线A要弱的。

对于光线C和D情况又不同了。C从表面的一侧入射,光线和表面的夹角为0,和法线垂直,对应的余弦值为0,这会导致光线C对表面照亮没有任何效果。光线D从表面的背面入射和法线成钝角,余弦值为负比0还小甚至小到-1。所以光线D和C一样都对物体表面没有照亮作用。

通过上面的分析我们可以得到一个很重要的结论:光线如果要对物体表面的亮度产生影响,那么光线和法线的角度要在0-90度之间但不包含90度。

可以看到表面法线对漫射光的计算很重要。上面的例子是很简化的:表面是平坦的直线只需要考虑一条法线。而真实世界中的物体有无数的多边形组成,每个多边形的法线和附近的多边形基本都不一样。例如:

http://ogldev.atspace.co.uk/www/tutorial18/normals.png

因为一个多边形面上分布的任意法向量都是一样的,足以用其中一个代表来计算顶点着色器中的漫射光。一个三角形上的三个顶点会有相同的颜色而且整个三角形面的颜色都相同,但这样看上去效果并不好,每个多边形之间的颜色值都不一样这样我们会看到多边形之间边界的颜色变化不平滑,因此这个明显是需要进行优化的。

优化的办法中使用到一个概念叫做‘顶点法线’。顶点法线是共用一个顶点的所有三角形法线的平均值。事实上我们并没有在顶点着色器中计算漫射光颜色,而只是将顶点法线作为一个成员属性传给片段着色器。光栅器会得到三个不同的法向量并对其之间进行插值运算。片段着色器将会对每个像素计算其特定的插值法向量对应的颜色值。这样使用那个插值后得到的每个像素特定法向量,我们对漫射光的计算可以达到像素级别。效果是光照效果在每个相邻三角形面之间会平滑的变化。这种技术叫做Phong着色(Phong Shading)。下面是顶点法线插值后的样子:

http://ogldev.atspace.co.uk/www/tutorial18/vertex_normals.png

但是我们会发现之前教程用的那个金字塔模型使用上面这些插值后的法向量计算优化后看上去很奇怪,有点想还是用本来没插值的法向量。这里是因为金字塔面很少,在后面更复杂的模型中使用上面的插值优化方法模型就会看上去更加平滑真实。

最有一点要关心的是漫射光计算所在的坐标空间。顶点和他们的法线都定义在本地坐标系空间,并且都在顶点着色器中被我们提供给shader的WVP矩阵进行了变换,然后到裁剪空间。然而,在世界坐标系中来定义光线的方向才是最合理的,毕竟光线的方向决定于世界空间中某个地方的光源将光线投射到某个方向(甚至太阳都是在世界空间中,只是距离极远)。所以,在计算之前,我们首先要将法线向量变换到世界坐标系空间。

代码详解

(lighting_technique.h:25)

struct DirectionalLight
{
    Vector3f Color;
    float AmbientIntensity;
    Vector3f Direction;
    float DiffuseIntensity;
};

这是新的平行光的数据结构,有两个新的成员变量:方向是定义在世界空间的一个3维向量,漫射光光照强度是一个浮点数(和环境光的用法一样)。

(lighting.vs)

#version 330

layout (location = 0) in vec3 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec3 Normal;

uniform mat4 gWVP;
uniform mat4 gWorld;

out vec2 TexCoord0;
out vec3 Normal0;

void main()
{
    gl_Position = gWVP * vec4(Position, 1.0);
    TexCoord0 = TexCoord;
    Normal0 = (gWorld * vec4(Normal, 0.0)).xyz;
}

这是更新了的顶点着色器,有一个新的顶点属性:法向量,这个法向量要由应用程序提供。另外世界变换有其自己的一致变量我们要和WVP变换矩阵一并提供。顶点着色器通过世界世界变换矩阵将法向量变换到世界空间并传递给片断着色器。注意这时候3维向量扩展成了4维向量,和4维世界变换矩阵相乘后又降回3维(…).xyz。GLSL的这种能力称作调配‘swizzling’,使向量操作非常灵活。比如,一个3维向量v(1,2,3),那么vec4 n = v.zzyy中4维向量n的内容为:(3,3,2,2)。注意如果我们要扩展3维向量到4维我们必须将第四个分量设置为0,这会使世界变换矩阵的变换效果(第4列)失效,因为向量不能像点一样移动,之只能缩放和旋转。

(lighting.fs:1)
#version 330

in vec2 TexCoord0;
in vec3 Normal0;

out vec4 FragColor;

struct DirectionalLight
{
    vec3 Color;
    float AmbientIntensity;
    float DiffuseIntensity;
    vec3 Direction;
};

这里是片段着色器的开始,它现在接受到插值后并在顶点着色器中转换到世界空间的顶点法向量。平行光的数据结构也扩展了来和C++中的对应匹配并且包含了新的光属性。

(lighting.fs:19)

void main()
{
    vec4 AmbientColor = vec4(gDirectionalLight.Color * gDirectionalLight.AmbientIntensity, 1.0f);
	// 环境光颜色参数的计算没有变化,我们计算并存储它然后用在下面最终的公式中。

    float DiffuseFactor = dot(normalize(Normal0), -gDirectionalLight.Direction);
    // 这是漫射光计算的核心。我们通过对光源向量和法线向量做点积计算他们之间夹角的余弦值。这里有三个注意点:
    1. 从顶点着色器传来的法向量在使用之前是经过单位化了的,因为经过插值之后法线向量的长度可能会变化不再是单位向量了;
    2.光源的方向被反过来了,因为本来光线垂直照射到表上时的方向和法线向量实际是相反的成180度角,计算的时候将光源方向取反那么垂直入射时和法线夹角为0,这时才和我们的计算相符合。
    3.光源向量不是单位化的。如果对所有像素的同一个向量都进行反复单位化会很浪费GPU资源。因此我们只要保证应用程序传递的向量在draw call之前被单位化即可。

    vec4 DiffuseColor;
    if (DiffuseFactor > 0) {
        DiffuseColor = vec4(gDirectionalLight.Color * gDirectionalLight.DiffuseIntensity * DiffuseFactor, 1.0f);
    }
    else {
        DiffuseColor = vec4(0, 0, 0, 0);
    }

	// 这里我们根据光的颜色、漫射光强度和光的方向来计算漫射光的部分。如果漫射参数是负的或者为0意味着光线是以一个钝角射到物体表面的(从水平一侧或者表面的背面),这时候光照是没有效果的同时漫射光的颜色参数会被初始化设置为零。如果夹角大于0我们就可以进行计算漫射光的颜色值了,将基本的颜色值和漫射光强度常量相乘,最后使用漫射参数DiffuseFactor对最后结果进行缩放。如果光是垂直入射那么漫射参数会是1,光的亮度最大。

    FragColor = texture2D(gSampler, TexCoord0.xy) * (AmbientColor + DiffuseColor);
}

这是最后的光照计算了。我们加入了环境光和漫射光的部分,并将结果和从纹理中取样得到的颜色相乘。现在可以看到即使漫射光方向太偏(照到反面或者从水平一侧)没有对表面起到照亮效果,环境光仍然能照亮物体,当然环境光也得要存在。

(lighting_technique.cpp:144)

void LightingTechnique::SetDirectionalLight(const DirectionalLight& Light)
{
    glUniform3f(m_dirLightLocation.Color, Light.Color.x, Light.Color.y, Light.Color.z);
    glUniform1f(m_dirLightLocation.AmbientIntensity, Light.AmbientIntensity);
    Vector3f Direction = Light.Direction;
    Direction.Normalize();
    glUniform3f(m_dirLightLocation.Direction, Direction.x, Direction.y, Direction.z);
    glUniform1f(m_dirLightLocation.DiffuseIntensity, Light.DiffuseIntensity);
}

这个函数将平行光的参数传递到着色器中,可以看到平行光的数据结构经过扩展后既包含了光的方向向量还包含了漫射光强度。注意方向向量在设置到shader着色器之前已经经过了单位化处理。同时LightingTechnique类也获取了光的方向和强度一致变量的的位置,也获得了世界变换矩阵的位置,另外还有一个设置世界变换矩阵的函数。这些目前都很常规没有放太多代码解释,具体的可以在源码里看。

tutorial18.cpp:35)

struct Vertex
{
    Vector3f m_pos;
    Vector2f m_tex;
    Vector3f m_normal;

    Vertex() {}

    Vertex(Vector3f pos, Vector2f tex)
    {
        m_pos = pos;
        m_tex = tex;
        m_normal = Vector3f(0.0f, 0.0f, 0.0f);
    }
};

这里最新的顶点的数据结构现在包含了法线向量,构造函数自动将其初始化为零,并且我们有一个专门的函数来遍历扫描所有的顶点并计算法向量。

(tutorial18.cpp:197)

void CalcNormals(const unsigned int* pIndices, unsigned int IndexCount, Vertex* pVertices, unsigned int VertexCount)
{
    for (unsigned int i = 0 ; i < IndexCount ; i += 3) {
        unsigned int Index0 = pIndices[i];
        unsigned int Index1 = pIndices[i + 1];
        unsigned int Index2 = pIndices[i + 2];
        Vector3f v1 = pVertices[Index1].m_pos - pVertices[Index0].m_pos;
        Vector3f v2 = pVertices[Index2].m_pos - pVertices[Index0].m_pos;
        Vector3f Normal = v1.Cross(v2);
        Normal.Normalize();

        pVertices[Index0].m_normal += Normal;
        pVertices[Index1].m_normal += Normal;
        pVertices[Index2].m_normal += Normal;
    }

    for (unsigned int i = 0 ; i < VertexCount ; i++) {
        pVertices[i].m_normal.Normalize();
    }
}

这个函数参数取得了顶点数组和索引数组,根据索引搜索取出每个三角形的三个顶点并计算其法向量。第一个循环中我们只累加计算三角形每个顶点的法向量。对于每个三角形法向量都是通过计算从第一个顶点出发到其他两个顶点的两条边向量的差积得到的。在向量累加之前要求先将其单位化,因为差积运算后的结果不一定是单位向量。第二个循环中,我们只遍历顶点数组(索引我们不关心了)并单位化每个顶点的法向量。这样操作等同于将累加的向量进行平均处理并留下一个为单位长度的顶点法线。这个函数在顶点缓冲器创建之前调用,在缓冲器中计算顶点法线,当然此时缓冲期中还会计算其他的一些顶点属性。

(tutorial18.cpp:131)

    const Matrix4f& WorldTransformation = p.GetWorldTrans();
    m_pEffect->SetWorldMatrix(WorldTransformation);
    ...
    glEnableVertexAttribArray(2);
    ...
    glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const GLvoid*)20);
    ...
    glDisableVertexAttribArray(2);

这是渲染循环中的主要变化,管线类有一个新的函数来提供世界变换矩阵(另外还有WVP变换矩阵)。世界变换矩阵是在缩放变换、旋转变换和平移变换时计算的。我们可以启用或禁用第三个顶点属性数组并且可以定义每个顶点法向量在顶点缓冲器中的偏移值。这里偏移值为20,因为之前已经被位置向量占用了12bytes,纹理坐标占用了8bytes。

为了实现这个教程中的图片效果我们还要定义漫射光强度和光的方向,这个是在tutorial18类中的构造函数中完成的。漫射光强度设置为0.8,光的方向从左向右。环境光强度逐渐动态地衰弱到0来增强体现漫射光的效果。你可以使用键盘的z和x键来调整漫射光强度(之前教程中是使用a和s键来调整环境光强度的)。

数学理论提示

很多网上的资源说要使用世界变换矩阵逆矩阵的转置来变换法向量,虽然没错,但我们通常不需要考虑那么远。我们的世界矩阵总是正交的(他们的向量都正交)。由于正交矩阵的逆和正交矩阵的转置是相同的,那么正交矩阵逆的转置实际就是其转置的转置,所以还是本来的矩阵。只要我们避免让图形变形扭曲(不成比例的在各轴线上进行缩放)那么就不会有问题的。

iOS面试基础概念篇

问题: 什么是谓词?

谓词(NSPredicate)是OC中针对数据集合的一种逻辑筛选条件,类似于数据库中SQL语句对数据筛选的限制约束条件。OC中的谓词经常用来从数组(Array)、集合(Set)等数据集合中筛选数据元素,谓词约束条件封装在NSPredicate对象中,可通过类函数predicateWithFormat和逻辑约束语句进行初始化。

首先这里以用谓词从对象数组筛选对象为例展示谓词的基本使用方法,谓词对象使用基本的逻辑约束格式化字符串来初始化:

    /* 其中Person是一个简单的数据模型,有name和age两个属性 */
    /* 数据源 */
    NSArray *objectArray = [[NSArray alloc] initWithObjects:
                            [Person personWithName:@"Amy" age:9],
                            [Person personWithName:@"Lily" age:10],
                            [Person personWithName:@"Sam" age:12],
                            [Person personWithName:@"Eric" age:18],nil];
    /* 谓词逻辑约束对象 */
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"age > 10"];
    
    /* 筛选数据(结果为Sam,Eric) */
    NSArray *newArray = [objectArray filteredArrayUsingPredicate:predicate];

上面的逻辑约束格式化字符串@"age > 10"指的是筛选对象中属性age大于10的所有对象,格式化字符串可以看做三部分:左手表达式 逻辑符号 右手表达式,其中这里的左手表达式是一个对象的属性键值(键路径),逻辑符号是一个基本的逻辑运算符, 右手表达式是约束范围。

逻辑运算符还有很多,和SQL语句中的基本相对应,除了最基本的逻辑运算符:>,==,<=,&&等,还有逻辑词:IN,CONTAINS,like等等:

	/* 1.IN:名字为Sam或者Eric的对象 */
	NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name IN {'Sam','Eric'}"];
	 /* 2.&&:年龄大于10,并且年龄小于20的对象(结果为Sam,Eric) */
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"age>10 && age<20"];
    /* 3.CONTAINS:名字里有小写字母a的对象(结果为Sam) */
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name CONTAINS 'a'"];
    /* 4.like:正则匹配,?表示一个占位符,*表示任意匹配 */
    /* 名字第三个字母为m的对象(结果为Sam) */
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name like '??m'"];
    /* 名字里有小写字母a的对象(结果为Sam) */
    NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name like '*a*'"];

问题: 什么是响应者链(Responder Chain)?

iOS中的响应者链是用于确定事件响应者的一种机制,其中的事件主要指的是触摸事件(Touch Event),该机制和UIKit中的UIResponder类紧密相关。响应触摸事件的都是屏幕上的界面元素,而且必须是继承自UIResponder类的界面类(包括各种常见的视图类及其视图控制器类,例如:UIView和UIViewController)才可以响应触摸事件。

一个事件响应者的完成主要经过两个过程:hitTest命中测试和响应者链确定响应者。前者是从顶部UIApplication往下传递(从父类到子类),直到找到命中者,然后从命中者视图沿着响应者链往上传递寻找真正的响应者。

首先一个界面的结构如图所示,最顶部是一个UIWindow窗口,其下对应一个唯一的根视图,跟视图上可以不断叠加嵌套各种子视图,构成一棵树(注意是父节点里面嵌套着子节点,即子节点的frame包含在父节点的frame内,而不是子节点就一定是父节点的子类,是组合关系而非继承关系)。

命中测试hitTest: 命中测试主要会用到视图类的hitTest函数和pointInside函数,前者用于递归寻找命中者,后者则是检测当前视图是否被命中,即触摸点坐标是否在视图内部。当触摸事件发生后,系统会将触摸事件以UIEvent的方式加入到UIApplication的事件队列中,UIApplication将事件分发给根部的UIWindow去处理,UIWindow则开始调用hitTest方法进行迭代命中检测。

命中检测具体迭代的过程为:如果触摸点在当前视图内,则递归对当前视图内部所有的子视图进行命中检测,如果不在当前视图内则返回NO停止迭代,这样最终会确定屏幕上最顶部的命中的视图元素,即命中者。

响应者链:

通过命中测试找到命中者后,任务并没有完成,因为最终的命中者不一定是事件的响应者,我们知道所谓的响应就是我们开发中为事件绑定的一个触发函数,事件发生后执行响应函数里的代码,例如通过addTarget方法为按钮的点击事件绑定响应函数,在按钮被点击后能及时执行我们想要做的任务。

一个继承自UIResponder的视图要想能响应事件,还需要满足一些条件:

  • 必须要有对应的视图控制器,因为按照MVC模式响应函数的逻辑代码要写在控制器内;另外userInteractionEnabled属性必须设置为YES,否则会忽视事件不响应;
  • hidden属性必须设置为NO,隐藏的视图不可以响应事件,类似的alpha透明度属性的值不能过低,低于0.01接近透明也会影响响应;
  • 最后要注意的是要保证树状结构的正确性,子节点的frame一定都要在父节点的frame内。

响应者链的结构和上面的树结构是对应的,是命中者节点所在的树的一条路径(加上视图节点对应的试图控制器),命中者的下一个响应者是它的试图控制器(如果存在的话),如果命中者不满足条件不能响应当前事件,则沿着响应者链往上寻找,看父节点能否响应,直到完成事件的响应。如果到了响应者链的顶端UIWindow事件依然没有被响应,则将事件交给UIApplication结束响应循环,此时事件则没有实质的响应动作发生。


问题: 什么叫懒加载(Lazy loading)?

答案:懒加载也就是延迟加载,将对象的实例化尽量延迟,直到真正用到的时候采取实例化要用的对象,而不是在程序初始化的开始就预先将对象实例化,以减轻大量对象实例化对资源的消耗。另外懒加载可以将对象的实例化代码从初始化函数中独立出来,提高了代码的可读性,更便于代码的组织。

最典型的一个例子是对象的getter方法中实例化对象,也就是重写getter方法,使得第一次调用getter方法时才实例化对象并将实例化的对象返回。判断是否是第一次调用getter方法是通过判断对象是否为空。懒加载的getter方法写法模板如下:

/**
 * getter
 */
- (NSObject *)object {
  if (!_object) {
    _object = [[NSObject alloc] init];
  }
  return _object;
}

通过重写getter方法实现懒加载的缺点是使得getter方法产生副作用,也就是破坏了getter方法的纯洁性,因为按照约定和习惯,getter方法就是作为接口简单地将需要的实例对象返回给外部,这里对getter方法的第一次调用添加了懒加载模式,在使用者不知情的情况下会有潜在的隐患。


问题: 什么是Cocoa和Cocoa Touch?

Cocoa和Cocoa Touch分别是OS X平台和iOS平台的应用开发环境,两个平台的环境都包含OC的运行时环境和两个核心框架:

Cocoa包括Foundation基础框架和AppKit界面开发框架,Cocoa环境是用来开发OS X系统上的应用的;

Cocoa Touch则包括Foundation基础框架和UIKit界面开发框架,用来开发iOS系统上的应用(主要指的就是iPhone和iPad)。

相关问题1: 构建iOS交互界面使用哪一个框架?

iOS平台开发交互界面的框架是UIKit,它提供了事件处理、绘制模型、窗口、视图以及触屏界面的控制器等等。相应的,在Mac上开发应用交互界面使用的框架为AppKit,二者不可混淆。UIKit归属于针对iOS开发的Cocoa Touch框架,而AppKit归属于OS X开发的Cocoa框架。

相关问题2: 说一下UIButton到NSObject之间的继承关系。

UIButton继承自UIControl,UIControl继承自UIView,UIView继承自UIResponder,UIResponder继承自最基本的类NSObject。 这里写图片描述


问题: 什么是iBeacons?

iBeacon是和NFC类似的苹果公司自家研发的近场通信技术,它是建立在BLE(低功耗蓝牙技术)基础上的。iBeacon的原理和蓝牙技术类似,它能够广播信号,移动设备的应用可以定位接受并反馈回应。

例如,在店铺里设置iBeacon通信模块,便可让iPhone和iPad上运行一资讯告知服务器,或者由服务器向顾客发送折扣券及进店积分。此外,还可以在家电发生故障或停止工作时使用iBeacon向应用软件发送资讯。


问题: “App ID”和“Bundle ID”有什么不同?他们分别是用来干什么的?

App ID是一个组合字符串,字符串包括两部分,一个是开发团队的ID,一个是标识应用的Bundle ID,之间用点隔开。开发团队的ID是苹果公司提供给开发者的,这个ID可以唯一标识一个开发团队,Bundle ID是开发者自定义的唯一标识一个应用的,一个团队的ID和不同的Bundle ID组合得到不同的App ID就可以标识该团队的不同的应用,开发者需要通过App ID来使自己的应用可以获取丰富的苹果服务,例如iCloud服务等等。

Bundle ID也就是App ID的后半部分,是一个App应用的唯一标识符,由开发者自定义,可以在Xcode工程中查看和设置(正式开发中一旦定义了是无法修改的,只能新建一个新的应用),在Xcode中全称叫做Bundle identifer。Bundle ID是唯一确定一个应用的,Bundle ID改变意味着变成了另一个应用,例如:同一个工程,我们发布时可能会有试用版和Pro版,或者免费版和付费版,就可以通过设置不同的Bundle ID来区分开。另外,如果让某个工程A运行安装到手机设备上,Bundle ID假设为”team.test”,然后打开另外一个工程B,将B的Bundle ID也设置为”team.test”,然后运行安装到同一个设备,会发现之前工程A安装的应用被覆盖掉了,这也证明了Bundle ID是唯一标识一个应用的。


问题: 什么是沙盒?

简单说沙盒就是系统中应用软件的一块相对封闭的独立空间,需要通过特殊限制通道才能访问沙盒外系统中的资源。沙盒是一种为了系统安全而为安装的应用设置的一种访问屏障,限制软件应用访问系统文件、系统偏好、网络资源和硬件设备等等,沙盒内的应用不会对系统的安全造成威胁。沙盒内是软件应用的内部空间,软件应用的内部空间即系统中该应用的沙盒目录,用来保存应用资源和数据等等。

为了帮助应用管理其数据,每一个沙盒目录都包含几个通用的子文件目录,用于放置应用文件。例如iOS开发中常见的:Document目录、tmp目录以及Library目录等等。

相关问题: iOS应用的沙盒文件目录都是什么?缓存文件存在哪个文件里?它的上一层是什么?

iOS沙盒内主要有三个文件目录:Documents目录,tmp目录,Library目录,另外还有应用的App文件。其中缓存文件在Library目录下,也就是Library/Caches目录,所以缓存文件的上一层是Library目录。

Documents:存放用户的应用程序数据(最好放在这里),iTunes备份恢复的时候会包括此目录,因此需要定期备份的数据要放在这里。

tmp:用来存放临时文件,不会被备份和恢复,程序退出后可能就被删除掉,因此存放的是程序重启后不再需要的信息和数据。

Library/Caches:存放缓存文件的地方,不会被备份但是引用程序退出后也不会被删除。

Library/Preferences:这个目录包含应用程序的偏好设置文件,应该使用NSUserDefaults类来取得和设置应用程序的偏好。

获取各种沙盒路径的方法:

    /* 1.获取Home目录路径: */
    NSString *homeDir = NSHomeDirectory();
    /* 2.获取Documents目录路径: */
    NSArray *docpaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *docDir = [docpaths objectAtIndex:0];
    /* 3.获取Library/Caches目录路径: */
    NSArray *cachepaths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
    NSString *cachesDir = [cachepaths objectAtIndex:0];
    /* 4.获取tmp目录路径: */
    NSString *tmpDir = NSTemporaryDirectory();
    /* 5.获取应用程序包中资源文件路径(获取程序包中一个图片资源'img.png'的路径): */
    NSString *imgPath = [[NSBundle mainBundle] pathForResource:@"img" ofType:@"png"];
    UIImage *image = [[UIImage alloc] initWithContentsOfFile:imgPath];

问题: 类工厂方法是什么?

简单说,类工厂方法就是用来快速创建对象的类方法,可以直接返回一个初始化好的对象。UIKit中最典型的就是UIButton的buttonWithType类工厂方法,指定按钮类型可以快速得到一个该类型的初始化好的按钮实例对象,该类工厂方法的定义为:

+ (instancetype)buttonWithType:(UIButtonType)buttonType;

使用示例:

UIButton *customButton = [UIButton buttonWithType:UIButtonTypeCustom];

类工厂方法的几个必备特征:

  • 一定是类方法;
  • 返回值一定是id/instancetype类型,因为要返回一个对象;
  • 规范的方法名会说明工厂方法返回的是什么对象,一般是类名首字母小写开始,例如这里buttonWithType说明返回的是一个button。

http://blog.csdn.net/totogo2010/article/details/8048652/

问题: iOS应用的生命周期?

应用生命周期的几个代理事件回调函数:

  • -(BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions

    告诉代理进程启动但还没进入状态保存

  • -(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

    告诉代理启动基本完成程序准备开始运行

  • -(void)applicationWillResignActive:(UIApplication *)application

    当应用程序将要入非活动状态执行,在此期间,应用程序不接收消息或事件,比如来电话了

  • -(void)applicationDidBecomeActive:(UIApplication *)application

    当应用程序入活动状态执行,这个刚好跟上面那个方法相反

  • -(void)applicationDidEnterBackground:(UIApplication *)application

    当程序被推送到后台的时候调用。所以要设置后台继续运行,则在这个函数里面设置即可

  • -(void)applicationWillEnterForeground:(UIApplication *)application

    当程序从后台将要重新回到前台时候调用,这个刚好跟上面的那个方法相反。

  • -(void)applicationWillTerminate:(UIApplication *)application

    当程序将要退出时被调用,通常是用来保存数据和一些退出前的清理工作。这个需要要设置UIApplicationExitsOnSuspend的键值。

  • -(void)applicationDidFinishLaunching:(UIApplication*)application当程序载入后执行

相关问题1: 什么时候可以说app不在运行状态?

  • 没有被运行起来的时候
  • 运行时被系统中断的时候

相关问题2: 什么时候可以说app处于活跃状态?

在前台运行且接受用户交互事件

相关问题3: app启动后会发生什么状态转换?

启动后应用初始化结束会先进入Background后台状态,正常情况会马上进入活跃状态,也可能运行失败进入挂起状态。

相关问题4: app在进入挂起状态前会先进入那个状态停留一会儿?

会先进入Background后台状态。

相关问题5: 列举并解释iOS应用的几种不同状态。

iOS中应用主要有以下几种状态:

  • 非运行状态(Not running state):

    指的是应用还没有被运行起来(用户没有点图标打开应用)或者正在运行的过程中被系统中断了的状态。

  • 不活跃状态(Inactive state):

    指的是应用在前台运行但是不接受事件交互(可能在执行一些其他的代码任务但不需要事件交互)。应用通常什么时候处于这种状态呢?例如,在运行某个应用时,用户突然锁屏了,或者突然强制转去处理一些像来电或短信等系统紧急事件了,但通常认为应用在没有事件处理的情况下都是处于这个状态的。

  • 活跃状态(Active state):

    活跃状态就是应用的正常使用状态了,这时候应用在前台运行且接受用户交互事件。

  • 后台状态(Background state):

    指的是应用被放到后台但还可能执行一些后台代码任务,通常进入到这个后台状态一会儿之后就会进入下面的挂起状态,不再执行代码,也就是应用是先进入后台状态然后再进入挂起状态的。

  • 挂起状态(Suspended state):

    在挂起状态,应用放在内存中但是不执行任何代码,处于‘睡眠’状态。当挂起的应用太多导致内存低时,系统可能会自行清理掉挂起的应用来腾出空间给前台活跃的应用。


问题: 什么是SpriteKit和SceneKit?

SpriteKit和SceneKit是苹果官方提供的分别用来开发2d游戏和3d游戏的框架,iOS平台开发游戏主要有以下几种框架或引擎:

目前苹果自带的游戏引擎有: 用于开发2D游戏的SpriteKit框架、开发3D游戏的SceneKit框架和底层的游戏图形库Metal。这三个框架引擎一起重新定义了基于iOS设备强大GPU的移动游戏开发。另外iOS平台常用的热门第三方2d游戏引擎为cocos引擎,包括cocos2d-iphone和cocos2d-x。

SpriteKit

SpriteKit是苹果官方提供的一个用来轻松开发2D游戏的游戏框架,拥有和一般2D游戏相同的游戏元素,例如:场景Scene、精灵Sprite、动作Action以及物理模拟引擎等等。

SceneKit

SceneKit是一个创建3D游戏,在APP应用中添加3D元素的高水平封装框架,可以用来轻松添加动画、物理模拟、粒子特效和基于现实的物理渲染等等。SceneKit没有像Metal和OpenGL那么底层,是经过专门封装后的渲染引擎提供方便调用的API。

Matel

Metal是和OpenGL类似但更加先进的底层图形库,提供并行加速计算,与GPU紧密联系,发挥GPU的强大性能并使CPU的负担最小化。

Cocos2d-iphone Cocos2d-iphone是著名的Cocos游戏引擎的iphone版本,也是最初的版本,专门用于在iOS设备上快速开发2D游戏应用,开发语言为Objective-C。

Cocos2d-x Cocos2d-x是一个源于Cocos2d-iphone的游戏引擎,它的主要特点是跨平台性和开源,开发语言为C++,大量继承了Cocos2d-iphone的特性,可以帮助开发者快速开发游戏应用并轻松移植到不同平台发布。


问题:iOS中static关键字的作用?

static关键字主要有两种作用:

第一,只想为某特定数据类型或对象分配单一的存储空间,而与创建对象的个数无关,例如:我们在使用OC实现单例模式的时候:

+ (instancetype *)sharedInstance {
    static dispatch_once_t once = 0;
    static id sharedInstance = nil;
    dispatch_once(&once, ^{
        // 只实例化一次
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

第二,希望某个方法或属性与类而不是对象关联在一起,也就是说,在不创建对象的情况下就可以通过类来直接调用方法或使用类的属性。

在C/C++或者Java等语言中,会通过static关键字定义静态变量或者静态方法来实现类名直接访问或者调用,而在OC中虽然兼容了C语言的static静态变量,但不可以在OC类中定义静态变量,静态变量只能在类文件内部使用(extern全局变量可在整个工程中使用);而类名直接调用的静态方法即OC中用’+’修饰的的静态方法,可通过类名直接调用,通常用于写对外接口,例如:抽象工厂方法等。

/* Test.h */
+ (void)classMethod;
...
/* Test.m */
+ (void)classMethod {
    NSLog(@"classMethod...");
}
...
/* main.m */
[Test classMethod];

一步步学OpenGL(17) -《环境光》

教程17

环境光

http://ogldev.atspace.co.uk/

原文: http://ogldev.atspace.co.uk/www/tutorial17/tutorial17.html

CSDN完整版专栏: http://blog.csdn.net/column/details/13062.html


背景

光照是是3D图形领域中一个最重要的对象之一。光照模型对于场景的渲染很重要,可以增添很多真实性效果。之所以叫做‘光照模型’是因为你不能去准确的去模拟现实世界的光照过程,因为现实中的光照是由大量的叫做‘光子’的粒子组成,并且同时有波动性离子性(光的波粒二象性)。如果要在程序中对每一个光子都进行相应的计算的话很快那么就会超出计算机的处理能力了,计算量太大。 因此,在过去的几年中人们建立了几种光照模型,来模拟光照射到物体上的主要效果并使物体可见。随着3D领域的快速发展和强大的计算机的出现这些模型也相应演变的越来越复杂。后面的教程中我们会学习几种基本的容易实现的光照模型,同时这些基本模型也是对场景效果表现最显著的。

基本的光照模型主要包括‘环境光/漫反射/镜面反射’。环境光是在晴天室外到处看到的光的类型,虽然太阳光穿过天空云层以不同角度照射到大地上,有很多地方肯定会被遮挡,但多数物体都是可见的,即使是在阴影下。这是因为光照射到物体上后都会向四处弹射,所以即使太阳光没有直接照射到的物体仍然可以发亮被看到。即使是房间里的一个灯泡也会和太阳光的原理一样散射环境光,因为房间不是很大所有的物体都被几乎均匀的点亮。环境光也就被建模为一个没有光源、没有方向并且对场景中的所有物体产生相同的点亮效果的一种光。

漫反射光强调的是光照射到物体表面的角度对物体亮度效果的影响。当光照射到物体的一面时这一面就会比其他一面要亮(其他面没有直接面向光源)。之前我们只说看到太阳光发射没有特定方向的环境光,但太阳光还有一个散射的特性,当太阳光照射到一个建筑物上时会看到照射到的一面比其他面要亮,漫反射光最重要的特性就是光的方向。

镜面反射光与其说是光本身的特性不如说是物体的一种属性,这种属性 是在入射光和观察者的视角都在某个特定的角度时会使物体高度发光,比如在晴天会看到小汽车的某个边缘会格外的发光耀眼。计算镜面反射光既要考虑光的入射角度又要考虑观察者的视角位置。

在3D应用中通常不需要直接分别创建环境光、漫反射和镜面反射光的光源,而是可以使用像室外的太阳、室内的灯泡或者洞穴里的手电筒这些光源。这些光源类型通常是有那三种光照模型和其他一些特殊属性的一些不同组合性质的光源。例如,一个手电筒会发出锥形的光源,离手电筒太远的物体根本不会被照亮。

下面的教程我们将会创建几种有用的光源类型同时学习基本的光照模型。 首先我们先学习一种叫做‘平行光directional light’的光源。平行光有特定的方向但是没有特定的光源,就是说所有的光都互相平行,平行光的方向使用一个向量来定义,并且这个向量将会在场景中所有物体的光照计算中用到,不管在什么位置。太阳光其实和平行光差不多了,如果非要考虑计算照射到两个建筑物上的太阳光之间的精确角度的话,会发现这这两束光基本上是平行的(会有极小的偏差),因为太阳离地球15000万千米远,因此我们可以直接不考虑太阳的位置,只考虑其方向就可以了。

平行光的另外一个重要性质是不管它离物体多远亮度是不变的(没有实际位置,不考虑光的衰减),这个和之后要学习的另外一种光源:点光源相反,点光源会随着距离增加而逐渐衰弱(灯泡就是个很好的例子)。

下面的图片表示的是平行光的性质: pic 我们已经知道太阳光中既有环境光的属性也有漫反射光的属性,这里我们先创建环境光的部分,下个教程再创建漫反射光的部分。

之前的教程我们学习了如何从纹理中采样颜色值。颜色有三个通道(红绿蓝),每个通道占一个byte,也就是颜色值范围是从0到255。各通道值的不同组合可以创建不同的颜色,每个通道的值都为0时是黑色,每个值都为255时是白色,其他值就是黑白之间的灰色或者彩色了。通过同时增大或者缩小各通道的值,可以在保持颜色不变的情况下使其变量或者变暗。

当白光照射到物体表面上是时反射的颜色就是物体表面的颜色,但亮度会随着光源强度变化,但还是那个颜色。如果光源是纯红色(255,0,0),那么反射的颜色也是偏红色了,因为红光没有绿色或蓝色通道的光可以从物体表面反射回来,如果同时物体表面是纯蓝色的话那结果就是纯黑色了。光只能暴露显示物体的实际颜色,但不能往上添加颜色。

光源的颜色我们会定义为一个包含三个浮点数的三元组,浮点数介于[0,1]之间(之后会和物体表面的颜色相乘,相当于各通道的颜色饱和率)。光源的颜色(那个三元组)和物体表面的颜色相乘就可以得到反射回来的颜色了。同时,我们还想将环境光的强度因素加入其中,那么环境光的强度参数就可以定义为一个[0,1]之间的一个单一的浮点数,然后和之前计算得到的反射回来的颜色值的每个通道都相乘,从而得到最终的颜色值。

下面的公式总结了环境光的计算过程:

在这个教程的示例代码中你可以通过调整‘a’和‘s’这两个参数来增大或者减小环境光的强度,看环境光对贴了纹理的金字塔模型的表面效果的影响。这里只有平行光中环境光的那一部分所以还没有引入方向的概念,下个教程中学习漫射光的时候就会加入光的方向了,这里暂时会看到金字塔会被均匀的照亮,不管从哪个角度去看。

环境光在很多情况下会被尽量的避免去考虑,因为它看上去有点太人工化,简单的实现并不会使场景更真实。使用一些更高级的技术比如全局光照会减少对环境光的需求,因为其实还要考虑光从物体表面反射后又照到其他物体上的事实。由于还没有到后面那些高级模型的学习阶段,这里就只是添加少量的环境光来避免出现物体一面被照亮而另一面完全是黑色的现象,因为最后要使光线看上去真实好看还需要调整很多的参数和其他不同的一些工作。

源代码详解

从现在开始我们的示例工程代码会变得越来越复杂,这个教程中,除了实现环境光,我们还会很大程度上重新组织构建我们的工程代码,使其方便用于后面的教程中,源码主要的变化有:

  1. 将shader的管理封装在一个Technique类中,包括编译和链接的一些工作。然后我们可以在Technique类的继承类中实现我们的一些可见效果。
  2. 将GLUT的初始化和回调管理移到GLUTBackend组件中,这个组件会注册接受来自GLUT的回调调用并将回调使用一个叫做ICallbacks的C++接口传送到应用中。
  3. 将主函数cpp文件中的全局函数和变量移到一个应用程序可以调用的类中,后面我们会将这个类扩展成一个用于所有应用的基础类,来提供通用的方法。这种架构设计方式在很多游戏引擎和框架中很流行。

除了光照模型定义的部分代码,这个教程中的多数代码都没有更新变化,只是将它们按照上面的方式重新组织了,所以只有下面的一些新的头文件更新了。

(glut_backend.h:24)

void GLUTBackendInit(int argc, char** argv);

bool GLUTBackendCreateWindow(unsigned int Width, unsigned int Height, unsigned int bpp, bool isFullScreen, const char* pTitle);

GLUT的很多变量生命的代码全都移到了一个”GLUT backend”组件中,这样就可以用上面的函数更加简单方便的来初始化GLUT以及创建一个窗口。

(glut_backend.h:28)

void GLUTBackendRun(ICallbacks* pCallbacks);

GLUT初始化并创建一个窗口之后下一步是使用上面的一个包装函数来执行GLUT主循环。这里添加的一点是一个ICallbacks接口来用于注册GLUT回调函数,这样就不用在每个应用中都在自己的GLUT中注册这些回调,还要注册他们自己的私有函数并将这些事件传给上面函数中定义的对象。应用的主要的类会经常实现这个接口并将自身作为参数用于GLUTBackendRun的调用。这个教程也采用了这种事件分配方法。

(technique.h:25)

class Technique
{
public:

   Technique();

   ~Technique();

   virtual bool Init();

   void Enable();

protected:

   bool AddShader(GLenum ShaderType, const char* pShaderText);

   bool Finalize();

   GLint GetUniformLocation(const char* pUniformName);

private:

   GLuint m_shaderProg;

   typedef std::list<GLuint> ShaderObjList;
   ShaderObjList m_shaderObjList;
};

在之前的教程中所有编译和链接的工作都放在主应用中,这里Technique这个类将一些通用的函数包装起来并允许衍生的类将工作集中到核心效果的展示上。

不管什么技术最开始都是要调用Init()函数进行初始化的,Technique的衍生类必须要先调用基类的Init()函数(要创建OpenGL程序对象)然后再添加衍生类自身的一些初始化操作。

Technique对象创建并初始化之后,通常下一步衍生子类会调用protected类型的AddShader()函数,来加载需要用到的GLSL着色器脚本(一段字符串序列)。最后,调用Finalize()函数来连接对象,Enable()函数实际是包装了glUseProgram()的函数,因此每当转到一个Technique对象都要及时调用这个函数并调用一个绘制函数。

这个类会一直跟随编译出的中间对象直到这个link链接调用glDeleteShader()函数来将他们删除。这个可以帮助减少你的应用消耗的资源的量。为了更好的性能表现,OpenGL应用经常在加载期间编译所有的shader而不是在运行时编译。及时移除释放掉不用的对象可以让应用减少对OpenGL资源的占用。程序对象自身会使用glDeleteProgram()在销毁阶段将自己删除掉。

(tutorial17.cpp:49)

class Tutorial17 : public ICallbacks
{
public:

 	 	Tutorial17()
 	 	{
 	 	 	 	...
 	 	}

 	 	~Tutorial17()
 	 	{
 	 	 	 	...
 	 	}

 	 	bool Init()
 	 	{
 	 	 	 	...
 	 	}

 	 	void Run()
 	 	{
 	 	 	 GLUTBackendRun(this);
 	 	}

 	 	virtual void RenderSceneCB()
 	 	{
 	 	 	 	...
 	 	}

 	 	virtual void IdleCB()
 	 	{
 	 	 	 	...
 	 	}

 	 	virtual void SpecialKeyboardCB(int Key, int x, int y)
 	 	{
 	 	 	 	...
 	 	}

 	 	virtual void KeyboardCB(unsigned char Key, int x, int y)
 	 	{
 	 	 	 	...
 	 	}

 	 	virtual void PassiveMouseCB(int x, int y)
 	 	{
 	 	 	 	...
 	 	}

private:

 	 	void CreateVertexBuffer()
 	 	{
 	 	 	 	...
 	 	}
 	 	void CreateIndexBuffer()
 	 	{
 	 	 	 	...
 	 	}

 	 	GLuint m_VBO;
 	 	GLuint m_IBO;
 	 	LightingTechnique* m_pEffect;
 	 	Texture* m_pTexture;
 	 	Camera* m_pGameCamera;
 	 	float m_scale;
 	 	DirectionalLight m_directionalLight;
};

这是将主程序中剩下的我们所熟悉的代码封装起来的一个类结构。Init()负责创建效果,加载纹理并创建顶点或者索引缓冲。Run()调用GLUTBackendRun()函数同时以它的对象本身为参数。由于类实现了ICallbacks接口,所有的GLUT事件都会在这个类中合适的方法中终止。另外之前全局文件区所有的全局变量现在类中都成了私有成员属性。

(lighting_technique.h:25)

struct DirectionalLight
{
 	 	Vector3f Color;
 	 	float AmbientIntensity;
};

这是平行光最开始的定义,现在只有环境光部分存在,而方向本身仍然是看不见不起作用的,下个教程引入漫反射光的时候我们会加入平行光的方向。上面这个数据结构包含两部分:光的颜色值和环境光的强度。光的颜色值决定着物体表面颜色值的那个通道的颜色可以反射回来以及各通道反射回来的强度。比如,光的颜色值如果是(1.0,0.5,0.0),那么红色通道将会被完全的反射回来,绿色通道的颜色值会削弱一半,蓝色通道会完全丢失反射不回来。这也是为什么物体表面只能反射回光有的颜色(光源按照不同通道的强度有多种),太阳光也就是白光各通道都很饱满,纯白色的光的颜色值为(1.0,1.0,1.0)。

环境光强度的定义决定了光源有多亮或者多暗。纯白光的强度为1.0所以物体会被完全照亮,而0.1强度的光源找到的物体虽然能看见但是很暗淡。

(lighting_technique.h:31)

class LightingTechnique : public Technique
{
public:

   	LightingTechnique();

   	virtual bool Init();

   	void SetWVP(const Matrix4f& WVP);
   	void SetTextureUnit(unsigned int TextureUnit);
   	void SetDirectionalLight(const DirectionalLight& Light);

private:

   	GLuint m_WVPLocation;
   	GLuint m_samplerLocation;
   	GLuint m_dirLightColorLocation;
   	GLuint m_dirLightAmbientIntensityLocation