注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

iOS 专用图层 七

6.9 CAEAGLLayer当iOS要处理高性能图形绘制,必要时就是OpenGL。应该说它应该是最后的杀手锏,至少对于非游戏的应用来说是的。因为相比Core Animation和UIkit框架,它不可思议地复杂。OpenGL提供了Core Animation...
继续阅读 »

6.9 CAEAGLLayer

当iOS要处理高性能图形绘制,必要时就是OpenGL。应该说它应该是最后的杀手锏,至少对于非游戏的应用来说是的。因为相比Core Animation和UIkit框架,它不可思议地复杂。

OpenGL提供了Core Animation的基础,它是底层的C接口,直接和iPhone,iPad的硬件通信,极少地抽象出来的方法。OpenGL没有对象或是图层的继承概念。它只是简单地处理三角形。OpenGL中所有东西都是3D空间中有颜色和纹理的三角形。用起来非常复杂和强大,但是用OpenGL绘制iOS用户界面就需要很多很多的工作了。

为了能够以高性能使用Core Animation,你需要判断你需要绘制哪种内容(矢量图形,例子,文本,等等),但后选择合适的图层去呈现这些内容,Core Animation中只有一些类型的内容是被高度优化的;所以如果你想绘制的东西并不能找到标准的图层类,想要得到高性能就比较费事情了。

因为OpenGL根本不会对你的内容进行假设,它能够绘制得相当快。利用OpenGL,你可以绘制任何你知道必要的集合信息和形状逻辑的内容。所以很多游戏都喜欢用OpenGL(这些情况下,Core Animation的限制就明显了:它优化过的内容类型并不一定能满足需求),但是这样依赖,方便的高度抽象接口就没了。

在iOS 5中,苹果引入了一个新的框架叫做GLKit,它去掉了一些设置OpenGL的复杂性,提供了一个叫做CLKViewUIView的子类,帮你处理大部分的设置和绘制工作。前提是各种各样的OpenGL绘图缓冲的底层可配置项仍然需要你用CAEAGLLayer完成,它是CALayer的一个子类,用来显示任意的OpenGL图形。

大部分情况下你都不需要手动设置CAEAGLLayer(假设用GLKView),过去的日子就不要再提了。特别的,我们将设置一个OpenGL ES 2.0的上下文,它是现代的iOS设备的标准做法。

尽管不需要GLKit也可以做到这一切,但是GLKit囊括了很多额外的工作,比如设置顶点和片段着色器,这些都以类C语言叫做GLSL自包含在程序中,同时在运行时载入到图形硬件中。编写GLSL代码和设置EAGLayer没有什么关系,所以我们将用GLKBaseEffect类将着色逻辑抽象出来。其他的事情,我们还是会有以往的方式。

在开始之前,你需要将GLKit和OpenGLES框架加入到你的项目中,然后就可以实现清单6.14中的代码,里面是设置一个GAEAGLLayer的最少工作,它使用了OpenGL ES 2.0 的绘图上下文,并渲染了一个有色三角(见图6.15).

清单6.14 用CAEAGLLayer绘制一个三角形

#import "ViewController.h"
#import
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *glView;
@property (nonatomic, strong) EAGLContext *glContext;
@property (nonatomic, strong) CAEAGLLayer *glLayer;
@property (nonatomic, assign) GLuint framebuffer;
@property (nonatomic, assign) GLuint colorRenderbuffer;
@property (nonatomic, assign) GLint framebufferWidth;
@property (nonatomic, assign) GLint framebufferHeight;
@property (nonatomic, strong) GLKBaseEffect *effect;

@end

@implementation ViewController

- (void)setUpBuffers
{
//set up frame buffer
glGenFramebuffers(1, &_framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);

//set up color render buffer
glGenRenderbuffers(1, &_colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorRenderbuffer);
[self.glContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.glLayer];
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_framebufferWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_framebufferHeight);

//check success
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
NSLog(@"Failed to make complete framebuffer object: %i", glCheckFramebufferStatus(GL_FRAMEBUFFER));
}
}

- (void)tearDownBuffers
{
if (_framebuffer) {
//delete framebuffer
glDeleteFramebuffers(1, &_framebuffer);
_framebuffer = 0;
}

if (_colorRenderbuffer) {
//delete color render buffer
glDeleteRenderbuffers(1, &_colorRenderbuffer);
_colorRenderbuffer = 0;
}
}

- (void)drawFrame {
//bind framebuffer & set viewport
glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);
glViewport(0, 0, _framebufferWidth, _framebufferHeight);

//bind shader program
[self.effect prepareToDraw];

//clear the screen
glClear(GL_COLOR_BUFFER_BIT); glClearColor(0.0, 0.0, 0.0, 1.0);

//set up vertices
GLfloat vertices[] = {
-0.5f, -0.5f, -1.0f, 0.0f, 0.5f, -1.0f, 0.5f, -0.5f, -1.0f,
};

//set up colors
GLfloat colors[] = {
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f,
};

//draw triangle
glEnableVertexAttribArray(GLKVertexAttribPosition);
glEnableVertexAttribArray(GLKVertexAttribColor);
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glVertexAttribPointer(GLKVertexAttribColor,4, GL_FLOAT, GL_FALSE, 0, colors);
glDrawArrays(GL_TRIANGLES, 0, 3);

//present render buffer
glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
[self.glContext presentRenderbuffer:GL_RENDERBUFFER];
}

- (void)viewDidLoad
{
[super viewDidLoad];
//set up context
self.glContext = [[EAGLContext alloc] initWithAPI: kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:self.glContext];

//set up layer
self.glLayer = [CAEAGLLayer layer];
self.glLayer.frame = self.glView.bounds;
[self.glView.layer addSublayer:self.glLayer];
self.glLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking:@NO, kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8};

//set up base effect
self.effect = [[GLKBaseEffect alloc] init];

//set up buffers
[self setUpBuffers];

//draw frame
[self drawFrame];
}

- (void)viewDidUnload
{
[self tearDownBuffers];
[super viewDidUnload];
}

- (void)dealloc
{
[self tearDownBuffers];
[EAGLContext setCurrentContext:nil];
}
@end

图6.15

图6.15 用OpenGL渲染的CAEAGLLayer图层

在一个真正的OpenGL应用中,我们可能会用NSTimerCADisplayLink周期性地每秒钟调用-drawRrame方法60次,同时会将几何图形生成和绘制分开以便不会每次都重新生成三角形的顶点(这样也可以让我们绘制其他的一些东西而不是一个三角形而已),不过上面这个例子已经足够演示了绘图原则了。

收起阅读 »

【插件&热修系列】ClassLoader方案设计

引言 上一个阶段我们开始进入插件/热修的领域,了解了热修的前世今生,下面我们来学习下热修中的ClassLoader方案设计; ClassLoader主要是用来加载插件用的,在启动插件前首先要把插件加载进来,下面我们通过不同方案分析,了解加载的不同姿势~ ...
继续阅读 »

引言


上一个阶段我们开始进入插件/热修的领域,了解了热修的前世今生,下面我们来学习下热修中的ClassLoader方案设计;


ClassLoader主要是用来加载插件用的,在启动插件前首先要把插件加载进来,下面我们通过不同方案分析,了解加载的不同姿势~


方案1:合并Dex(hook方式)


谁用了这个方案?


QQ团队的空间换肤功能


原理


将我们插件dex和宿主apk的class.dex合并,都放到宿主dexElements数组中。App每次启动从该数组中加载。


实战流程


1)获取宿主,dexElements


2)获取插件,dexElements


3)合并两个dexElements


4)将新的dexElements 赋值到 宿主dexElements


代码


Class<?> clazz = Class.forName("dalvik.system.BaseDexClassLoader");
Field pathListField = clazz.getDeclaredField("pathList");
pathListField.setAccessible(true);

Class<?> dexPathListClass = Class.forName("dalvik.system.DexPathList");
Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
dexElementsField.setAccessible(true);

// 宿主的 类加载器
ClassLoader pathClassLoader = context.getClassLoader();
// DexPathList类的对象
Object hostPathList = pathListField.get(pathClassLoader);
// 宿主的 dexElements
Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList);

// 插件的 类加载器
ClassLoader dexClassLoader = new DexClassLoader(apkPath, context.getCacheDir().getAbsolutePath(),null, pathClassLoader);
// DexPathList类的对象
Object pluginPathList = pathListField.get(dexClassLoader);
// 插件的 dexElements
Object[] pluginDexElements = (Object[]) dexElementsField.get(pluginPathList);

// 宿主dexElements = 宿主dexElements + 插件dexElements
// 创建一个新数组
Object[] newDexElements = (Object[]) Array.newInstance(hostDexElements.getClass().getComponentType(),hostDexElements.length + pluginDexElements.length);
// 拷贝
System.arraycopy(hostDexElements, 0, newDexElements,0, hostDexElements.length);
System.arraycopy(pluginDexElements, 0,newDexElements,hostDexElements.length, pluginDexElements.length);

// 赋值
dexElementsField.set(hostPathList, newDexElements);


特点


此乃单ClassLoader方案,插件和宿主程序的类全部都通过宿主的ClasLoader加载,存在的短板为“插件之间或者插件与宿主之间使用的类库有相同的时候,那么就会加载乱序等问题”


方案2:替换 PathClassloader 的 parent


谁用了这个方案?


微店、Instant-Run


知识基础


安装在手机里的apk(宿主)的ClassLoader链路关系


1)代码:


ClassLoader classLoader = getClassLoader();
ClassLoader parentClassLoader = classLoader.getParent();
ClassLoader pParentClassLoader = parentClassLoader.getParent();

2)关系:


==classLoader==:dalvik.system.PathClassLoader


==parentClassLoader==:java.lang.BootClassLoader


==pParentClassLoader==:null


可以看出,当前的classLoader是PathClassLoader,parent的ClassLoader是BootClassLoader,而BootClassLoader没有parent的ClassLoader


实现思想


如何利用上面的宿主链路基础原理设计?


ClassLoader的构造方法中有一个参数是parent; 如果把PathClassLoader的parent替换成我们==插件的classLoader==; 再把==插件的classLoader的parent==设置成BootClassLoader; 加上父委托的机制,查找插件类的过程就变成:BootClassLoader->==插件的classLoader==->PathClassLoader


代码实现


public static void loadApk(Context context, String apkPath) {
File dexFile = context.getDir("dex", Context.MODE_PRIVATE);
File apkFile = new File(apkPath);
//找到 PathClassLoader
ClassLoader classLoader = context.getClassLoader();
//构建插件的 ClassLoader
//PathClassLoader 的父亲 传递给 插件的ClassLoader
//到这里,顺序为:BootClassLoader->插件的classLoader
DexClassLoader dexClassLoader = new DexClassLoader(apkFile.getAbsolutePath(),dexFile.getAbsolutePath(), null,classLoader.getParent());
try {
//PathClassLoader 的父亲设置为 插件的ClassLoader
//顺序为:BootClassLoader->插件的classLoader->PathClassLoader
Field fieldClassLoader = ClassLoader.class.getDeclaredField("parent");
if (fieldClassLoader != null) {
fieldClassLoader.setAccessible(true);
fieldClassLoader.set(classLoader, dexClassLoader);
}
} catch (Exception e) {
e.printStackTrace();
}
}

特点


此乃单ClassLoader方案,插件和宿主程序的类全部都通过宿主的ClasLoader加载,存在的短板为“插件之间或者插件与宿主之间使用的类库有相同的时候,那么就会加载乱序等问题”


方案3:利用LoadedApk的缓存机制


谁用了这个方案?


360的DroidPlugin


实现原理


java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);
StrictMode.incrementExpectedActivityCount(activity.getClass());
r.intent.setExtrasClassLoader(cl);

上面代码做了两件事:


1)系统用packageInfo.getClassLoader()来加载已安装app的Activity


2)实例化的Activity


其中packageInfo为LoadedApk类型,是APK文件在内存中的表示,Apk文件的相关信息,诸如Apk文件的代码和资源,甚至代码里面的Activity,Service等组件的信息我们都可以通过此对象获取。


packageInfo怎么生成的?通过阅读源码得出:


1)先在ActivityThread中的mPackages缓存(Map,key为包名,value为LoadedApk)中获取


2)如果缓存没有,new LoadedApk 生成一个,然后放到缓存mPackages中


基于上面系统的原理,实现的关键点步骤:


1)构建插件 ApplicationInfo 信息


ApplicationInfo applicationInfo = (ApplicationInfo) generateApplicationInfoMethod.invoke(packageParser,packageObj, 0, defaultPackageUserState);
String apkPath = apkFile.getPath();
applicationInfo.sourceDir = apkPath;
applicationInfo.publicSourceDir = apkPath;

2)构建 CompatibilityInfo


Class<?> compatibilityInfoClass = Class.forName("android.content.res.CompatibilityInfo");
Field defaultCompatibilityInfoField = compatibilityInfoClass.getDeclaredField("DEFAULT_COMPATIBILITY_INFO");
defaultCompatibilityInfoField.setAccessible(true);
Object defaultCompatibilityInfo = defaultCompatibilityInfoField.get(null);

3)根据 ApplicationInfo 和 CompatibilityInfo,构建插件的 loadedApk


Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method getPackageInfoNoCheckMethod = activityThreadClass.getDeclaredMethod("getPackageInfoNoCheck", ApplicationInfo.class, compatibilityInfoClass);
Object loadedApk = getPackageInfoNoCheckMethod.invoke(currentActivityThread, applicationInfo, defaultCompatibilityInfo);

4)构建插件的ClassLoader,然后把它替换到插件loadedApk的ClassLoader中


String odexPath = Utils.getPluginOptDexDir(applicationInfo.packageName).getPath();
String libDir = Utils.getPluginLibDir(applicationInfo.packageName).getPath();
ClassLoader classLoader = new DexClassLoader(apkFile.getPath(), odexPath, libDir, ClassLoader.getSystemClassLoader());
Field mClassLoaderField = loadedApk.getClass().getDeclaredField("mClassLoader");
mClassLoaderField.setAccessible(true);
mClassLoaderField.set(loadedApk, classLoader);

5)把插件loadedApk添加进ActivityThread的mPackages中


// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object currentActivityThread = currentActivityThreadMethod.invoke(null);
// 获取到 mPackages 这个静态成员变量, 这里缓存了dex包的信息
Field mPackagesField = activityThreadClass.getDeclaredField("mPackages");
mPackagesField.setAccessible(true);
Map mPackages = (Map) mPackagesField.get(currentActivityThread);

// 由于是弱引用, 因此我们必须在某个地方存一份, 不然容易被GC; 那么就前功尽弃了.
sLoadedApk.put(applicationInfo.packageName, loadedApk);
WeakReference weakReference = new WeakReference(loadedApk);
mPackages.put(applicationInfo.packageName, weakReference);

6)绕过系统检查,让系统觉得插件已经安装在系统上了


private static void hookPackageManager() throws Exception {
// 这一步是因为 initializeJavaContextClassLoader 这个方法内部无意中检查了这个包是否在系统安装
// 如果没有安装, 直接抛出异常, 这里需要临时Hook掉 PMS, 绕过这个检查.
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 获取ActivityThread里面原始的 sPackageManager
Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
sPackageManagerField.setAccessible(true);
Object sPackageManager = sPackageManagerField.get(currentActivityThread);

// 准备好代理对象, 用来替换原始的对象
Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
Object proxy = Proxy.newProxyInstance(iPackageManagerInterface.getClassLoader(),
new Class<?>[] { iPackageManagerInterface },
new IPackageManagerHookHandler(sPackageManager));

// 1. 替换掉ActivityThread里面的 sPackageManager 字段
sPackageManagerField.set(currentActivityThread, proxy);
}


特点


1)自定义了插件的ClassLoader,并且绕开了Framework的检测


2)Hook的地方也有点多:不仅需要Hook AMS和H,还需要Hook ActivityThread的mPackages和PackageManager!


3)多ClassLoader构架,每一个插件都有一个自己的ClassLoader,隔离性好,如果不同的插件使用了同一个库的不同版本,它们相安无事


4)真正完成代码的热加载!


插件需要升级,直接重新创建一个自定的ClassLoader加载新的插件,然后替换掉原来的版本即可(Java中,不同ClassLoader加载的同一个类被认为是不同的类)


单ClassLoader的话实现非常麻烦,有可能需要重启进程。


方案4:自定义ClassLoader逻辑


谁用了?


腾讯视频等事业群中的Shadow热修框架


实现原理


1)先了解下宿主(已经安装App)的ClassLoader链路: BootClassLoader -> PathClassLoader


2)插件可以加载宿主的类实现:


构建插件的ClassLoader,名字为ApkClassLoader,其中父加载器传的是宿主的ClassLoader,代码片段为:


class ApkClassLoader extends DexClassLoader {

static final String TAG = "daviAndroid";
private ClassLoader mGrandParent;
private final String[] mInterfacePackageNames;

@Deprecated
ApkClassLoader(InstalledApk installedApk,
ClassLoader parent,////parent = 宿主ClassLoader
String[] mInterfacePackageNames,
int grandTimes) {

super(installedApk.apkFilePath, installedApk.oDexPath, installedApk.libraryPath, parent);

在这个流程下,插件查找的流程变为: BootClassLoader -> PathClassLoader -> ApkClassLoader(其实就是双亲委托)


3)插件不需要加载宿主的类实现:


class ApkClassLoader extends DexClassLoader {

............
//1)系统里面找
Class<?> clazz = findLoadedClass(className);
if (clazz == null) {
//2)从自己的dexPath中查找
clazz = findClass(className);
if (clazz == null) {
//3)从parent的parent找(BootClassLoader)ClassLoader中查找。
clazz = mGrandParent.loadClass(className);
}
}

............
}

这个逻辑插件不需要加载宿主的类,所以加载逻辑中不会去加载宿主的类(也就是会经过PathClassLoader),这种情况下,即使插件和宿主用到了同一个类,那么插件加载的时候不会因为委托加载机制而去加载了宿主的,导致插件的加载错了;


代码实现


class ApkClassLoader extends DexClassLoader {
private ClassLoader mGrandParent;
private final String[] mInterfacePackageNames;

ApkClassLoader(InstalledApk installedApk,
ClassLoader parent, String[] mInterfacePackageNames, int grandTimes) {
super(installedApk.apkFilePath, installedApk.oDexPath, installedApk.libraryPath, parent);
ClassLoader grand = parent;
for (int i = 0; i < grandTimes; i++) {
grand = grand.getParent();
}
mGrandParent = grand;
this.mInterfacePackageNames = mInterfacePackageNames;
}

@Override
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
String packageName;
int dot = className.lastIndexOf('.');
if (dot != -1) {
packageName = className.substring(0, dot);
} else {
packageName = "";
}

boolean isInterface = false;
for (String interfacePackageName : mInterfacePackageNames) {
if (packageName.equals(interfacePackageName)) {
isInterface = true;
break;
}
}

if (isInterface) {
return super.loadClass(className, resolve);
} else {
Class<?> clazz = findLoadedClass(className);

if (clazz == null) {
ClassNotFoundException suppressed = null;
try {
clazz = findClass(className);
} catch (ClassNotFoundException e) {
suppressed = e;
}

if (clazz == null) {
try {
clazz = mGrandParent.loadClass(className);
} catch (ClassNotFoundException e) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
e.addSuppressed(suppressed);
}
throw e;
}
}
}

return clazz;
}
}

/**
* 从apk中读取接口的实现
*
* @param clazz 接口类
* @param className 实现类的类名
* @param <T> 接口类型
* @return 所需接口
* @throws Exception
*/
<T> T getInterface(Class<T> clazz, String className) throws Exception {
try {
Class<?> interfaceImplementClass = loadClass(className);
Object interfaceImplement = interfaceImplementClass.newInstance();
return clazz.cast(interfaceImplement);
} catch (ClassNotFoundException | InstantiationException
| ClassCastException | IllegalAccessException e) {
throw new Exception(e);
}
}

}

该代码实现不正常的双亲委派逻辑,既能和parent隔离类加载(和宿主),也能通过白名单复用一些宿主的类


特点


1)属于多ClassLoader方案


2)插件可以选择加载宿主的类和绕过宿主加载,选择性强


结尾


哈哈,该篇就写到这里(一起体系化学习,一起成长)


收起阅读 »

Suspension(挂起/暂停) 在Kotlin coroutines里面到底是如何工作的?

前言 挂起函数是Kotlin协程的标志。挂起功能也是其中最重要的功能,所有其他功能都建立在此基础上。这也是为什么在这篇文章中,我们目标是深入了解它的工作原理。 挂起一个协程(suspending a coroutine)意味着在其(代码块)执行过程中中断(挂起...
继续阅读 »

前言


挂起函数是Kotlin协程的标志。挂起功能也是其中最重要的功能,所有其他功能都建立在此基础上。这也是为什么在这篇文章中,我们目标是深入了解它的工作原理。


挂起一个协程(suspending a coroutine)意味着在其(代码块)执行过程中中断(挂起)它。和咱们停止玩电脑单机游戏很类似: 你保存并关闭了游戏,紧接着你和你的电脑又去干其他不同的事儿去了。然后,过了一段时间,你想继续玩游戏。所以你重新打开游戏,恢复之前保存的位置,继续从你之前玩的地方开始玩起了游戏。


上面所讲的场景是协程的一个形象比喻。他们(任务/一段代码)可以被中断(挂起去执行去他任务),当他们要回来(任务执行完成)的时候,他们通过返回一个Continuation(指定了我们恢复到的位置)。我们可以用它(Continuation)来继续我们的任务从之前我们中断的地方。


Resume(恢复)


那么我们来看一下它(Resume)的实际效果。首先,我们需要一个协程代码块。创建协程的最简单方式是直接写一个suspend函数,下面这段代码是我们的起始点:


suspend fun testCoroutine() {
println("Before")

println("After")
}
//依次输出
//Before
//After


上面代码很简单:会依次输出“Before”和“After”。这个时候如果我们在两行代码中间挂起的话会发生什么?为了到达挂起的效果,我们可以使用kotlin标准库提供的suspendCoroutine方法:


suspend fun testCoroutine() {
println("Before")

suspendCoroutine<Unit> {

}

println("After")
}

//依次输出
//Before

如果你调用上面的代码,你将不会看到”After“,而且这个代码将会一直运行下去(也就是说我们的testCoroutine方法不会结束)。这个协程在打印完”Before“后就被挂起了。我们的代码快被中断了,而且不会被恢复。所以?我们该怎么做呢?哪里有提到Continuation(可以主动恢复)吗?


再看一下suspendCoroutine的调用, 而且注意它是以一个lambda表达式结尾。这个方法在挂起前给我们传递了一个参数,它的类型是Continuation


uspend fun testCoroutine() {
println("Before")

suspendCoroutine<Unit> { continuation ->
println("Before too")
}

println("After")
}

//依次输出
//Before
//Before too

上面的代码添加了: 在lambda表达式里面调用了另外一个方法, 好吧,这不是啥新鲜事儿。这个就和letapply等类似。suspendCoroutine方法需要这样子设计以便在协程挂起之前就拿到了continuation。如果suspendCoroutine执行了,那就晚了,所以lambda表达式将会在挂起前被调用。这样子设计的好处就是可以在某些时机可以恢复或者存储continuation。so 我们可以让continuation立即恢复


suspend fun testCoroutine() {
println("Before")

suspendCoroutine<Unit> { continuation ->
continuation.resume(Unit)
}

println("After")
}

//依次输出
//Before
//After

我们也可以用它来开启一个新的线程,而且还延迟了一会儿才恢复它:


suspend fun testCoroutine() {
println("Before")

suspendCoroutine<Unit> { continuation ->
thread {
Thread.sleep(1000)
continuation.resume(Unit)
}
}

println("After")
}

//依次输出
//Before
//(1秒以后)
//After

这是一个重要的发现。注意,新启动一个线程的代码可以提到一个方法里面,而且恢复可以通过回调来触发。在这种情况下,continuation将被lambda表达式捕获:


fun invokeAfterSecond(operation: () -> Unit) {
thread {
Thread.sleep(1000)
operation.invoke()
}
}

suspend fun testCoroutine() {
println("Before")

suspendCoroutine<Unit> { continuation ->
invokeAfterSecond {
continuation.resume(Unit)
}
}

println("After")
}

//依次输出
//Before
//(1秒以后)
//After


这种机制是有效的,但是上面的代码我们没必要通过创建线程来做。线程是昂贵的,所以为啥子要浪费它们?一种更好的方式是设置一个闹钟。在JVM上面,我们可以使用ScheduledExecutorService。我们可以使用它来触发*continuation.resume(Unit)*在一定时间后:


private val executor = Executors.newSingleThreadScheduledExecutor {
Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun testCoroutine() {
println("Before")


suspendCoroutine<Unit> { continuation ->
executor.schedule({
continuation.resume(Unit)
}, 1000, TimeUnit.MILLISECONDS)
}

println("After")
}

//依次输出
//Before
//(1秒以后)
//After

“挂起一定时间后恢复” 看起来像是一个很常用的功能。那我们就把它提到一个方法内,并且我们将这个方法命名为delay


private val executor = Executors.newSingleThreadScheduledExecutor {
Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun delay(time: Long) = suspendCoroutine<Unit> { cont ->
executor.schedule({
cont.resume(Unit)
}, time, TimeUnit.MILLISECONDS)
}

suspend fun testCoroutine() {
println("Before")

delay(1000)

println("After")
}

//依次输出
//Before
//(1秒以后)
//After

实际上上面的代码就是kotlin协程库delay的具体实现。我们的实现比较复杂,主要是为了支持测试,但是本质思想是一样的。


Resuming with value(带值恢复)


有件事可能一直让你感到疑惑:为啥我们调用resume方法的时候传递的是Unit?也有可能你会问为啥子我写suspendCoroutine方法的时候前面也带了Unit类型。实际上这两个是同一类型不是巧合:一个作为continuation恢复的时候入参类型,一个作为suspendCoroutine方法的返回值类型(指定我们要返回什么类型的值),这两个类型要保持一致:


val ret: Unit =
suspendCoroutine<Unit> { continuation ->
continuation.resume(Unit)
}

当我们调用suspendCoroutine,我们决定了continuation恢复时候的数据类型,当然这个恢复时候返回的数据也作为了suspendCoroutine方法的返回值:


suspend fun testCoroutine() {

val i: Int = suspendCoroutine<Int> { continuation ->
continuation.resume(42)
}
println(i)//42

val str: String = suspendCoroutine<String> { continuation ->
continuation.resume("Some text")
}
println(str)//Some text

val b: Boolean = suspendCoroutine<Boolean> { continuation ->
continuation.resume(true)
}
println(b)//true
}

上面这些代码好像和咱们之前聊得游戏有点不一样,没有任何一款游戏可以在恢复进度得时候你可以携带一些东西(除非你作弊或者谷歌了下知道下一个挑战是什么)。但是上面代码有返回值的设计方式对于协程来说却意义非凡。我们经常挂起是因为我们需要等待一些数据。比如,我们需要通过API网络请求获取数据,这是一个很常见的场景。一个线程正在处理业务逻辑,处理到某个点的时候,我们需要一些数据才能继续往下执行,这个时候我们通过网络库去请求数据并返回给我们。如果没有协程,这个线程则需要停下来等待。这是一个巨大的浪费---线程资源是非常昂贵的。尤其当这个线程是很重要的线程的时候,就像Android里面的Main Thread。但是有了协程就不一样了,这个网络请求只需要挂起,然后我们给网络请求库传递一个带有自我介绍的continuation:”一旦你获取到数据了,就将他们扔到我的resume方法里面“。然后这个线程就可以去做其他事儿了。一旦数据返回了,当前或其他方法(依赖于我们设置的dispatcher)就会从之前协程挂起的地方继续执行了。


紧着我们实践一波,通过回调函数来模拟一下我们的网络库:


data class User(val name: String)

fun requestUser(callback: (User) -> Unit) {
thread {
Thread.sleep(1000)
callback.invoke(User("hyy"))
}
}
suspend fun testCoroutine() {
println("Before")

val user: User =
suspendCoroutine<User> { continuation ->
requestUser {
continuation.resume(it)
}
}

println(user)
println("After")
}

//依次输出
//Before
//(1秒以后)
//User(name=hyy)
//After

直接调用suspendCoroutine不是很方便,我们可以抽取一个挂起函数来替代:


suspend fun requestUser(): User {
return suspendCoroutine<User> { continuation ->
requestUser {
continuation.resume(it)
}
}
}
suspend fun testCoroutine() {
println("Before")

val user = requestUser()

println(user)
println("After")
}

现在,你很少需要包装回调函数以使其成为挂起函数,因为很多流行库(RetrofitRoom等)都已经支持挂起函数了。但从另方面来讲,我们已经对那些函数的底层实现有了一些了解。它就和我们刚才写的类似。不一样的是,底层使用的是suspendCancellableCoroutine函数(支持取消)。后面我们会讲到。


suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { continuation ->
requestUser {
continuation.resume(it)
}
}
}

你可能想知道如果API接口没给我们返回数据而是抛出了异常,比如服务死机或者返回一些错误。这种情况下,我们不能返回数据,相反我们需要在协程挂起的地方抛出异常。这是我们在异常情况下恢复地方。


Resume with exception(异常恢复)


我们调用的每个函数可能返回一些值也可能抛异常。就像suspendCoroutine: 当resume调用的时候返回正常值, 当resumeWithException调用的时候,则会在挂起点抛出异常:


class MyException : Throwable("Just an exception")

suspend fun testCoroutine() {

try {
suspendCoroutine<Unit> { continuation ->
continuation.resumeWithException(MyException())
}
} catch (e: MyException) {
println("Caught!")
}
}

//Caught

这种机制是为了处理各种不同的问题。比如,标识网络异常:


suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { cont ->
requestUser { resp ->
if (resp.isSuccessful) {
cont.resume(resp.data)
} else {
val e = ApiException(
resp.code,
resp.message
)
cont.resumeWithException(e)
}
}
}
}

翻译不动了。。。😂, 就差不多到这吧。。


结尾


我希望现在您可以从用户的角度清楚的了解挂起(暂停)是如何工作的。Best wishes!


原文地址:kt.academy/article/cc-…


作者:老炮儿丶狗二
链接:https://juejin.cn/post/6999461797140889614
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

ConstraintLayout2.0一篇写不完之极坐标布局与动画

相对于一般布局方式的笛卡尔坐标系,MotionLayout还拓展了ConstraintLayout中的相对中心布局方式,我们暂且称之为「极坐标布局」方式。 极坐标布局方式在某些场景下,比笛卡尔坐标系的建立更加方便,特别是涉及到一些圆周运动和相对中心点运动的场...
继续阅读 »

相对于一般布局方式的笛卡尔坐标系,MotionLayout还拓展了ConstraintLayout中的相对中心布局方式,我们暂且称之为「极坐标布局」方式。


极坐标布局方式在某些场景下,比笛卡尔坐标系的建立更加方便,特别是涉及到一些圆周运动和相对中心点运动的场景。


Rotational OnSwipe


在OnSwipe的基础上,极坐标方式拓展了运动的方向,给dragDirection增加了dragClockwise和dragAnticlockwise参数,用于设置OnSwipe的顺时针滑动和逆时针滑动,这两个属性,在设置rotationCenterId后才会生效。那么借助这个,就可以很方便的实现一些圆形路径的滑动效果和动画。


通过下面这个例子,我们来看下Rotational OnSwipe的使用方法。


首先,极坐标的布局还是借助ConstraintLayout,代码如下所示。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/motionLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#cfc"
app:layoutDescription="@xml/motion_01_dial_scene"
app:motionDebug="SHOW_ALL">

<TextView
android:id="@+id/number1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textSize="24sp"
app:layout_constraintCircle="@id/dial"
app:layout_constraintCircleAngle="73"
app:layout_constraintCircleRadius="112dp"
app:layout_constraintTag="hop" />

......

<TextView
android:id="@+id/number0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="24sp"
app:layout_constraintCircle="@id/dial"
app:layout_constraintCircleAngle="172"
app:layout_constraintCircleRadius="112dp"
app:layout_constraintTag="hop" />

<ImageView
android:id="@+id/dial"
android:layout_width="300dp"
android:layout_height="300dp"
android:src="@drawable/dial"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.6"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTag="center"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.8" />

<ImageView
android:id="@+id/dialhook"
android:layout_width="70dp"
android:layout_height="70dp"
android:src="@drawable/dial_hook"
app:layout_constraintCircle="@id/dial"
app:layout_constraintCircleAngle="122"
app:layout_constraintCircleRadius="112dp"
app:layout_constraintTag="hop" />

</androidx.constraintlayout.motion.widget.MotionLayout>


极坐标布局就是借助layout_constraintCircle、layout_constraintCircleAngle、layout_constraintCircleRadius来确定圆心、角度和半径,从而实现极坐标的布局,接下来,再通过OnSwipe来实现圆形滑动效果。


<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto"
motion:defaultDuration="2000">

<ConstraintSet android:id="@+id/start">
<Constraint android:id="@+id/dial">
<Transform android:rotation="0" />
</Constraint>
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
<Constraint android:id="@+id/dial">
<Transform android:rotation="300" />
</Constraint>
</ConstraintSet>

<Transition
motion:autoTransition="animateToStart"
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000"
motion:motionInterpolator="easeIn">

<OnSwipe
motion:dragDirection="dragClockwise"
motion:dragScale=".9"
motion:maxAcceleration="10"
motion:maxVelocity="50"
motion:onTouchUp="autoCompleteToStart"
motion:rotationCenterId="@id/dial" />
<KeyFrameSet>

</KeyFrameSet>
</Transition>
</MotionScene>

核心就在OnSwipe中,设置rotationCenterId后,再设置滑动的方向为顺时针即可,展示如下所示。


image-20302


Relative Animation


在MotionLayout中,它进一步加强了在动画中对极坐标运动的支持,特别是一些极坐标的相对运动动画,可以通过MotionLayout,以非常简单的方式表现出来。我们举个简单的例子,一个行星环绕的动画,如下所示。


image-208867


我们可以发现,这个动画的轨迹是非常复杂的,太阳以自己为中心自传,地球绕着太阳旋转的同时还在自传,月球绕着地球旋转,卫星绕着地球旋转的同时,逐渐远离地球,靠近月球。


这样一个复杂的极坐标动画效果,虽然借助ConstraintLayout可以很方便的实现定位布局,但是运动时,却无法继续保持极坐标的依赖关系,所以,这里需要使用MotionLayout来维持运动时的极坐标约束关系。


首先,使用ConstraintLayout来完成起始布局的建立,代码如下所示。


<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/motionLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF003b60"
app:layoutDescription="@xml/motion_01_motion_scene"
app:motionDebug="SHOW_ALL">

<ImageView
android:id="@+id/sun"
android:layout_width="180dp"
android:layout_height="180dp"
android:src="@drawable/sun"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent" />

<TextView
android:id="@+id/rocket"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="?"
android:textSize="28sp"
app:layout_constraintCircle="@id/earth"
app:layout_constraintCircleAngle="0"
app:layout_constraintCircleRadius="60dp" />

<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/moon"
android:layout_width="16dp"
android:layout_height="16dp"
android:rotation="-240"
android:src="@drawable/moon"
app:layout_constraintCircle="@id/earth"
app:layout_constraintCircleAngle="0"
app:layout_constraintCircleRadius="180dp" />

<androidx.constraintlayout.utils.widget.ImageFilterView
android:id="@+id/earth"
android:layout_width="160dp"
android:layout_height="160dp"
android:src="@drawable/earth"
app:layout_constraintCircle="@id/sun"
app:layout_constraintCircleAngle="315"
app:layout_constraintCircleRadius="200dp" />

</androidx.constraintlayout.motion.widget.MotionLayout>

接下来,在Scene文件中,设置相对运动关系,代码如下所示。


<ConstraintSet android:id="@+id/start">

<Constraint android:id="@id/earth">
<Motion motion:animateRelativeTo="@+id/sun" />
</Constraint>

<Constraint android:id="@id/moon">
<Motion motion:animateRelativeTo="@+id/earth" />
</Constraint>

<Constraint android:id="@+id/rocket">
<Motion
motion:animateRelativeTo="@+id/earth"
motion:motionPathRotate="45" />
</Constraint>
</ConstraintSet>

借助animateRelativeTo来实现Motion中的相对中心点,使用motionPathRotate来设置旋转的角度。



Motion标签中的motionPathRotate和Constraint标签中的transitionPathRotate的作用,都是让其相对于Path旋转一定角度。



MotionLayout中新增的属性非常多,大家可以参考我的这些文章,从各个方面,逐个击破MotionLayout的各个难点。

收起阅读 »

Android分区存储常见问题解答

要在 Google Play 上发布,开发者需要将应用的 目标 API 级别 (targetSdkVersion) 更新到 API 级别 30 (Android 11) 或者更高版本。针对新上架的应用,这个政策自 8 月开始生效;现有应用更新新的版本,这个政策...
继续阅读 »

要在 Google Play 上发布,开发者需要将应用的 目标 API 级别 (targetSdkVersion) 更新到 API 级别 30 (Android 11) 或者更高版本。针对新上架的应用,这个政策自 8 月开始生效;现有应用更新新的版本,这个政策的要求将自 11 月开始生效。


API 30 所带来的一个巨大变更是,应用需要使用分区存储 (Scoped Storage)。


变更之大,对于大型应用来说堪称恐怖。更糟糕的是,我们在网上看到的有关如何适配分区存储的建议,有一些建议十分令人迷惑,甚至会误导我们。


为了帮您排忧解难,我们收集了一些有关分区存储的常见问题,同时也为如何适配您的应用提供了一些建议和可能的替代方案。


Q: android:requestLegacyStorage 会被移除吗?


A: 部分移除。


如果您的应用当前已经设置了 android:requestLegacyStorage="true",就应该在 targetSdkVersion 设置为 30 后保持现状。该标记在 Android 11 设备中没有任何效果,但是可以继续让应用在 Android 10 设备上以旧的方式访问存储。


如果您需要针对 Android 10 设备在 AndroidManifest.xml 中设置 android:requestLegacyStorage="true",那在应用的目标版本改为 Android 11 后应当保留此设置。它仍会在 Android 10 设备上生效。


Q: android:preserveLegacyStorage 是如何工作的?


A: 如果您的应用安装在 Android 10 设备上,并设置了 android:requestLegacyStorage="true",那在设备升级至 Android 11 后,此设置会继续保持旧的存储访问方式。


?? 如果应用被卸载,或者是第一次在 Android 11 上安装,那么就无法使用旧的存储访问方式。此标记仅适用于进一步帮助设备从传统存储升级到分区存储。


Q: 如果我的应用没有访问照片、视频或音频文件,是否仍然需要请求 READ_EXTERNAL_STORAGE 权限?


A: 不需要,从 Android 11 开始,仅在访问其他应用所属的媒体文件时才需要请求 READ_EXTERNAL_STORAGE 权限。如果您的应用仅使用自身创建的非媒体文件 (或自身创建的媒体文件),那么就不再需要请求该权限。


如需在 Android 11 后停止请求该权限,仅需修改应用 AndroidManifest.xml 文件中的 <uses-permission> 标签,添加 android:maxSdkVersion="29" 即可:


<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />

Q: 我想要访问不属于我应用的照片、视频或一段音频,我必须使用系统文件选择器吗?


A: 不。但如果您想用就可以用,ACTION_OPEN_DOCUMENT 最早可支持至 Android KitKat (API 19),而 ACTION_GET_CONTENT 则支持至 API 1,二者使用的都是系统文件选择器。由于不需要任何权限,这仍然是首选的解决方案。


如果您不想使用系统文件选择器,您仍然可以请求 READ_EXTERNAL_STORAGE 权限,它会使您的应用可以访问所有的照片、视频以及音频文件,同时也包含访问 File API 的权限!


如果您需要使用 File API 访问媒体内容,记得设置 android:requestLegacyStorage="true",否则 File API 在 Android 10 中将无法工作。


Q: 我想保存非媒体文件,但我不想在卸载我的应用时删除它们。我需要使用 SAF 吗?


A: 也许需要。


如果这些文件允许在应用外打开而无需通过您的应用,那么系统文件选择器是较好的选择。您可以使用 ACTION_CREATE_DOCUMENT 创建文件。当然也可以使用 ACTION_OPEN_DOCUMENT 来打开一个现有文件。


如果应用曾经创建了一个目录用于存储所有这些文件,那最好的选择就是使用系统文件选择器和 ACTION_OPEN_DOCUMENT_TREE,以便用户可以选择要使用的特定文件夹。


如果这些文件只对您的应用有意义,可以考虑在应用 AndroidManifest.xml 文件的 <application> 标签中设置 android:hasFragileUserData="true"。这将使用户可以保留这些数据,即使在卸载应用时亦是如此。


上图为拥有 "脆弱用户数据" 应用的卸载对话框。对话框中包含了一个复选框,用于指示系统是否应该保留应用数据。


△ 上图为拥有 "脆弱用户数据" 应用的卸载对话框。对话框中包含了一个复选框,用于指示系统是否应该保留应用数据。


设置了该标记后,存储文件的最佳位置将取决于其内容。包含敏感或私人信息的文件应当存储在 Context#getFilesDir() 所返回的目录中;而不敏感的数据则应存储于 Context#getExternalFilesDir() 所返回的目录中。


Q: 我可以将非媒体文件放置于其他文件夹中 (例如 Downloads 文件夹),而无需任何权限。这是一个 Bug 吗?


A: 不是。应用可能会向这类集合提供文件,而且最好的方式是对非媒体文件同时使用 Downloads 和 Documents 集合。不过请记得,默认情况下只有创建该文件的应用才可以访问它们。其他应用需要通过系统文件选择器获得访问权限或者拥有对外部存储的广泛访问权限 (即: MANAGE_EXTERNAL_STORAGE 权限) 才行。


?? 对 MANAGE_EXTERNAL_STORAGE 权限的访问受到 Play 政策 监管。


Q: 如果我需要保存一个文档,是否需要使用 SAF?


A: 不用。应用可以向 Documents 与 Downloads 集合提供非媒体文件,而无需任何特殊权限。只要没被卸载,那么向这些集合提供文档的应用拥有这些文档的完全访问权限。


? 如果您的应用为了上面提到的方式保存文档而请求 READ_EXTERNAL_STORAGE 权限的话,在 Android 11 及更高版本中将不必再请求该权限。您可以参考下面的示例修改对该权限的请求 (设定 maxSdkVersion 为 API 版本 29):


<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />

如要访问其他应用添加的文档,或者在您的应用卸载重装后访问其卸载前添加的文档,就需要通过 ACTION_OPEN_DOCUMENT Intent 来使用系统文件选择器。


Q: 我想要与其他应用共享文件,是否需要使用 SAF?


A: 不需要。如下是一些与其他应用共享文件的方式:



  • 直接分享: 使用 Intent.ACTION_SEND 可以让您的用户通过各种格式与设备上的其他应用共享数据。如果您使用这种方式,使用 AndroidX 的 FileProvider 来将 file:// Uri 自动转换为 content:// Uri 可能会对您有所帮助。

  • 创建您自己的 DocumentProvider: 这可以让您的应用继续处理应用的私有目录 ( Context#getFilesDirs() 或 Context#getExternalFilesDirs()) 中内容的同时,仍可以向使用系统文件选择器的其他应用提供访问权限。(请注意,可以在卸载应用后继续保存这些文件——参阅上文中的 android:hasFragileUserData="true" 设置来了解其使用方式。)


最后的思考


Scoped Storage 是一项旨在改善用户隐私保护的重大变更。不过仍然有很多方法可以处理不依赖使用存储访问框架 (Storage Access Framework) 的内容。


如果要存储的数据仅适用于您的应用,那么我们强烈建议使用 应用特定目录


如果数据是媒体文件,例如照片、视频或者音频,那么可以 使用 MediaStore。注意,从 Android 10 开始,提供内容 不再需要请求权限


也别忘了可以通过 ACTION_SEND 来与 其他应用共享数据 (或允许它们 与您的应用共享数据)!


收起阅读 »

一文读懂 Android 主流屏幕适配方案

公众号:字节数组,希望对你有所帮助 ?? 关于 Android 的屏幕适配现在已经有很多成熟的方案了,已经不是一个热门话题了。印象中 2018 年是讨论适配方案最火热的一段时间,那时候字节跳动技术团队发文介绍了其适配方案,之后就带动起了很多位大佬陆续发表...
继续阅读 »

公众号:字节数组,希望对你有所帮助 ??



关于 Android 的屏幕适配现在已经有很多成熟的方案了,已经不是一个热门话题了。印象中 2018 年是讨论适配方案最火热的一段时间,那时候字节跳动技术团队发文介绍了其适配方案,之后就带动起了很多位大佬陆续发表文章进行讨论。当时我刚工作不久,在面试时也有被问到过关于屏幕适配的问题,因为对于一些概念认识不清导致回答得并不好 ?? 所以本篇文章就想要从头到尾讲清楚关于屏幕适配的主要知识点,希望对你有所帮助,有错误也希望读者能够指出来 ??


一、ppi & dpi


关于屏幕适配有两个绕不开的概念:ppi 和 dpi,两者在含义上很类似,很容易混淆,但其实是属于不同领域上的概念


ppi


ppi(Pixels Per Inch)即像素密度,指每英寸包含的物理像素的数量。ppi 是设备在物理上的属性值,取决于屏幕自身,计算公式如下所示。被除数和除数都属于客观不可改变的值,所以 ppi 也是无法修改的,是硬件上一个客观存在无法改变的值



dpi


dpi(Dots Per Inch)原先用于在印刷行业中描述每英寸包含有多少个点,在 Android 开发中则用来描述屏幕像素密度。屏幕像素密度决定了在软件概念上单位距离对应的像素总数,是手机在出厂时就会被写入系统配置文件中的一个属性值,一般情况下用户是无法修改该值的,但在开发者模式中有修改该值的入口,是软件上一个可以修改的值


我们知道,在不同手机屏幕上 1 dp 所对应的 px 值可能是会有很大差异的。例如,在小屏幕手机上 1 dp 可能对应 1 px,在大屏幕手机上对应的可能是 3 px,这也是我们实现屏幕适配的基础原理。决定了在特定一台手机上 1 dp 对应多少 px 的正是该设备的 dpi 值,这可以通过 DisplayMetrics 来获取


val displayMetrics = applicationContext.resources.displayMetrics
Log.e("TAG", "densityDpi: " + displayMetrics.densityDpi)
Log.e("TAG", "density: " + displayMetrics.density)
Log.e("TAG", "widthPixels: " + displayMetrics.widthPixels)
Log.e("TAG", "heightPixels: " + displayMetrics.heightPixels)

TAG: densityDpi: 480
TAG: density: 3.0
TAG: widthPixels: 1080
TAG: heightPixels: 2259

从中就可以提取出几点信息:



  1. 屏幕像素密度为 480 dpi

  2. density 等于 3,说明在该设备上 1 dp 等于 3 px

  3. 屏幕宽高大小为 2259 x 1080 px,即 753 x 360 dp


Android 系统定义的屏幕像素密度基准值是 160 dpi,该基准值下 1 dp 就等于 1 px,依此类推 480 dpi 下 1 dp 就等于 3 px,计算公式:


px = dp * (dpi / 160)

不同屏幕像素密度的设备就对应了不同的配置限定符。例如,在 320 到 480 dpi 之间的设备就对应 xxhdpi,该类型设备在取图片时就会优先从 drawable-xxhdpi 文件夹下取



二、为什么要适配


不管我们在布局文件中使用的是什么单位,最终系统在使用时都需要将其转换为 px,由于不同手机的屏幕像素尺寸会相差很大,我们自然不能在布局文件中直接使用 px 进行硬编码。因此 Google 官方也推荐开发者尽量使用 dp 作为单位值,因为系统会根据屏幕的实际情况自动完成 dp 与 px 之间的对应换算


举个例子。假设设计师给出来的设计稿是按照 1080 x 1920 px,420 dpi 的标准来进行设计的,那么设计稿的宽高即 411 x 731 dp,那对于一个希望占据屏幕一半宽度的 ImageView 来说,在设计稿中的宽即 205.5 dp


那么,对于一台 1440 x 2880 px,560 dpi 的真机来说,其宽高即 411 x 822 dp,此时我们在布局文件中就可以直接使用设计稿中给出来的宽度值,使得 ImageView 在这台真机上也占据了屏幕一半宽度。虽然设计稿和真机的屏幕像素并不相同,但由于屏幕像素密度的存在,使得两者的 dp 宽度是一样的,从而令开发者可以只使用同一套 dp 尺寸值就完成设计要求了


既然有了 dp,那我们为什么还需要进行屏幕适配呢?当然也是因为 dp 只适用于大部分正常情况了。以上情况之所以能够完美适配,那也是因为举的例子刚好也是完美的:1440 / 1080 = 560 / 420 = 1.3333,设计稿和真机的 px 宽度和 dp 宽度刚好具有相同比例,此时使用 dp 才能刚好适用


再来看一个不怎么完美的例子。以两台真机为例:



  • 华为 nova5:1080 x 2259 px,480 dpi,屏幕宽度为 1080 / (480 / 160) = 360 dp

  • 三星 Galaxy S10:1080 x 2137 px,420 dpi,屏幕宽度为 1080 / (420 / 160) = 411 dp


可以看到,在像素宽度相同的情况下,不同手机的像素密度是有可能不一样的。手机厂家有可能是根据屏幕像素和屏幕尺寸来共同决定该值的大小,但不管怎样,这就造成了应用的实际效果与设计稿之间无法对应的情况:对于一个 180 dp 宽度的 View 来说,在华为 nova5 上能占据一半的屏幕宽度,但在三星 Galaxy S10 上却只有 180 / 411 = 0.43,这就造成了一定偏差


以上情况就是直接使用 dp 值无法解决的问题,使用 dp 只能适配大部分宽高比例比较常规的机型,对于特殊机型就无能为力了……


屏幕适配就是要来解决上述问题。对于屏幕适配,开发者希望实现的效果主要有两个:



  • 在声明宽高值时,能够直接套用设计稿上给出来的尺寸值,这个尺寸值映射到项目中可能是对应一个具体的值,也可能是对应多套 dimens 文件中的值,但不管是哪一种,在开发阶段都希望能够直接套用而无需再来进行手动计算。这关乎进行屏幕适配的效率

  • 适配后的界面最终在不同屏幕上的空间比例都能保持一致。这关乎进行屏幕适配的最终成效


下面就来介绍三种当前比较主流或曾经是主流的的适配方案 ~~


三、今日头条方案


字节跳动技术团队曾经发布过一篇文章介绍了其适配方案:一种极低成本的Android屏幕适配方式


其适配思路基于以下几条换算公式:



  • px = density * dp

  • density = dpi / 160

  • px = dp * (dpi / 160)


在布局文件中声明的 dp 值,最终都需要通过 TypedValue 的 applyDimension 方法来转换为 px,转换公式即:density * dp


    public static float applyDimension(int unit, float value, DisplayMetrics metrics) {
switch (unit) {
case COMPLEX_UNIT_PX:
return value;
case COMPLEX_UNIT_DIP:
return value * metrics.density;
case COMPLEX_UNIT_SP:
return value * metrics.scaledDensity;
case COMPLEX_UNIT_PT:
return value * metrics.xdpi * (1.0f/72);
case COMPLEX_UNIT_IN:
return value * metrics.xdpi;
case COMPLEX_UNIT_MM:
return value * metrics.xdpi * (1.0f/25.4f);
}
return 0;
}

那么,如果我们能够动态修改 density 值的大小,要求修改后计算出的屏幕宽度就等于设计稿的宽度,不就可以在布局文件中直接使用设计稿给出的各个 dp 宽高值,且使得 View 在不同手机屏幕上都能占据同样的比例吗?


举个例子,假设设计师给出来的设计稿是按照 **1080 x 1920 px,density 2.625,420 dpi ** 的标准来进行设计的,设计稿的宽高即 411 x 731 dp。那么对于一个宽度为 100 dp 的 View,占据设计稿的宽度比例是:100 * 2.625 / 1080 = 0.2430


用以下两台真机的数据为例,在适配前:



  • 华为 nova5:1080 x 2259 px,480 dpi。正常情况下其 density 为 3,View 占据的屏幕宽度比例是:100 x 3 / 1080 = 0.2777

  • Pixel 2 XL:1440 x 2800 px,560 dpi。正常情况下其 density 为 3.5,View 占据的屏幕宽度比例是:100 x 3.5 / 1440 = 0.2430


采用字节跳动技术团队的方案动态改变 density 进行适配,适配后的 density = 设备真实宽度(单位 px) / 设计稿的宽度(单位 dp):



  • 华为 nova5:适配后 density 变成 1080 / 411 = 2.6277,View 占据的屏幕宽度比例是:100 x 2.6277 / 1080 = 0.2433

  • Pixel 2 XL:适配后 density 变成 1440 / 411 = 3.5036,View 占据的屏幕宽度比例是:100 x 3.5036 / 1440 = 0.2433


可以看出来,虽然由于除法运算会导致一点点精度丢失,但完全可以忽略不计,只要我们能动态改变手机的 density,最终 View 在宽度上就都能保持和设计稿完全相同的比例了


实际上 density 只是 DisplayMetrics 类中的一个 public 变量,不涉及任何私有 API,修改后理论上也不会影响到应用的稳定性。因此,只要我们在 Activity 的 onCreate 方法中完成对 density 和 densityDpi 的修改,我们就可以在布局文件中直接使用设计稿给出的 dp 值,不用准备多套 dimens 就能完成适配,十分简洁


    fun setCustomDensity(activity: Activity, application: Application, designWidthDp: Int) {
val appDisplayMetrics = application.resources.displayMetrics
val targetDensity = 1.0f * appDisplayMetrics.widthPixels / designWidthDp
val targetDensityDpi = (targetDensity * 160).toInt()
appDisplayMetrics.density = targetDensity
appDisplayMetrics.densityDpi = targetDensityDpi
val activityDisplayMetrics = activity.resources.displayMetrics
activityDisplayMetrics.density = targetDensity
activityDisplayMetrics.densityDpi = targetDensityDpi
}

override fun onCreate(savedInstanceState: Bundle?) {
setCustomDensity(this, application, 420)
super.onCreate(savedInstanceState)
}


字节跳动技术团队的文章只给出了示例代码,并没有给出最终落地可用的代码,但在 GitHub 上有一个挺出名的落地实践库,读者值得一看:AndroidAutoSize



四、宽高限定符


宽高限定符是系统原生支持的一种适配方案,通过穷举市面上所有 Android 手机的屏幕像素尺寸来实现适配。实现思路很简单,就是通过比例换算来为不同分辨率的屏幕分别生成一套 dimens 文件


首先,以设计稿的尺寸作为基准分辨率,假设设计稿是 1920 x 1080 px,那么就可以先生成默认的 dimens 文件,生成规则:



  • 将屏幕宽度均分为 1080 份,每份 1 px,声明 1080 个 key 值,值从 1 px 开始递增,每次递增 1 px

  • 将屏幕高度均分为 1920 份,每份 1 px,声明 1920 个 key 值,值从 1 px 开始递增,每次递增 1 px


最终 dimens 文件就像以下这样:


<resources>
<dimen name="x1">1px</dimen>
<dimen name="x2">2px</dimen>
···
<dimen name="x1080">1080px</dimen>

<dimen name="y1">1px</dimen>
<dimen name="y2">2px</dimen>
···
<dimen name="y1920">1920px</dimen>
</resources>

类似地,再来为屏幕尺寸为 1440 x 720 px 的手机生成专属的 dimens 文件,生成规则:



  • 将屏幕宽度均分为 1080 份,每份 720 / 1080 = 0.666 px,声明 1080 个 key 值,值从 0.666 px 开始递增,每次递增 0.666 px

  • 将屏幕高度均分为 1920 份,每份 1440 / 1920 = 0.75 px,声明 1920 个 key 值,值从 0.75 px 开始递增,每次递增 0.75 px


最终 dimens 文件就像以下这样:


<resources>
<dimen name="x1">0.666px</dimen>
<dimen name="x2">1.332px</dimen>
···
<dimen name="x1080">720px</dimen>

<dimen name="y1">0.75px</dimen>
<dimen name="y2">1.5px</dimen>
···
<dimen name="y1920">1440px</dimen>
</resources>

最终,为市面上主流的屏幕尺寸均按照如上规则生成一套专属的 dimens 文件,每套文件均放到以像素尺寸进行命名的 value 文件夹下,就像以下这样:


values
values-1440x720
values-1920x1080
values-2400x1080
values-2408x1080
values-2560x1440

之后,我们就可以直接套用设计稿中的像素尺寸进行开发了,设计稿写的是 100 x 200 px,那么我们在布局文件中就可以直接引用 x100 和 y200。当应用运行在不同分辨率的手机中时,应用会自动去引用相同分辨率的 dimens 文件,此时引用到的实际 px 值具有和设计稿相同的比例大小,这样就实现了适配需求了


需要注意,宽高限定符方案有一个致命缺陷:需要精准命中分辨率才能实现适配。比如 1920 x 1080 px 的手机就一定要引用到 values-1920x1080文件夹内的 dimens 文件,否则就只能去引用默认的 values 文件夹,此时引用到的尺寸值就有可能和实际需求有很大出入,从而导致界面变形。而对于市面上层出不穷的各种分辨率,开发者想穷举完其实很麻烦,所以说,宽高限定符方案的容错率很低


五、smallestWidth


smallestWidth 也是系统原生支持的一种适配方案。smallestWidth 即最小宽度,指的是最短的那一个边长,而不考虑屏幕的方向,适配原理和宽高限定符方案一样,本质上都是通过比例换算来为不同尺寸的屏幕分别准备一套 dimens 文件,应用在运行时再去引用相匹配的 dimens 文件,以此来实现屏幕适配


首先,我们要以设计稿的尺寸作为基准分辨率,假设设计师给出来的设计稿是按照 **1080 x 1920 px **的标准来进行设计的,那么基准分辨率就是设计稿的宽度 1080 px


先为宽度为 360 dp 的设备生成 dimens 文件,生成规则:



  • 将 360 dp 均分为 1080 份,每份 360 / 1080 dp,声明 1080 个 key 值,值从 360 / 1080 dp 开始递增,每次递增 360 / 1080 dp


最终 dimens 文件就像以下这样:


<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="DIMEN_1PX">0.33dp</dimen>
<dimen name="DIMEN_2PX">0.67dp</dimen>
···
<dimen name="DIMEN_1078PX">359.33dp</dimen>
<dimen name="DIMEN_1079PX">359.67dp</dimen>
<dimen name="DIMEN_1080PX">360.00dp</dimen>
</resources>

类似地,我们再按照上述规则为宽度为 380 dp 的设备生成 dimens 文件:


<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="DIMEN_1PX">0.35dp</dimen>
<dimen name="DIMEN_2PX">0.70dp</dimen>
···
<dimen name="DIMEN_1078PX">379.30dp</dimen>
<dimen name="DIMEN_1079PX">379.65dp</dimen>
<dimen name="DIMEN_1080PX">380.00dp</dimen>
</resources>

最终,为市面上主流的屏幕宽度均按照如上规则生成一套专属的 dimens 文件,每套文件均放到以宽度进行命名的 value 文件夹内,就像以下这样:


values
values-sw360dp
values-sw380dp
values-sw400dp
values-sw420dp

这样,我们就可以直接在布局文件中套用设计稿的 px 值了,应用在运行时就会自动去匹配最符合当前屏幕宽度的资源文件。例如,如果我们引用了 DIMEN_1080PX,那么不管是在宽度为 360 dp 还是 380 dp 的设备中,该引用对应的 dp 值都是刚好占满屏幕宽度,这样就实现了适配需求了


smallestWidth 方案和宽高限定符方案最大的差别就在于容错率,smallestWidth 方案具有很高的容错率,即使应用中没有找到符合当前屏幕宽度的 dimens 文件,应用也会向下寻找并采用最接近当前屏幕宽度的 dimens 文件,只有都找不到时才会去引用默认的 dimens 文件。只要我们准备的 dimens 足够多,且每套 dimens 文件以 5 ~ 10 dp 作为步长递增,那么就能够很好地满足市面上的绝大部分手机了。此外,我们不仅可以使用设计稿的 px 宽度作为基准分辨率,也可以改为使用 dp 宽度,计算规则还是保持一致


六、总结


以上介绍的三种方案各有特点,这里来做个总结



  • 今日头条方案。优点:可以直接使用设计稿中的 dp 值,无需生成多套 dimens 文件进行映射,因此不会增大 apk 体积。此外,此方案的 UI 还原度在三种方案中应该是最高的了,其它两种方案都需要精准命中屏幕尺寸后才能达到此方案的还原度。缺点:由于此方案会影响到应用全局,因此如果我们引入了一些第三方库的话,三方库中的界面也会随之被影响到,可能会造成效果变形,此时就需要进行额外处理了

  • 宽高限定符方案。容错率太低,且需要准备很多套 dimens 文件,在 Android 刚兴起,屏幕类型还比较少的时候比较吃香,目前应该已经很少有项目采用此方案了,读者可以直接忽略

  • smallestWidth 方案。优点:容错率高,在 320 ~ 460 dp 之间每 10 dp 就提供一套 dimens 文件就足够使用了,想要囊括更多设备的话也可以再缩短步长,基本不用担心最终效果会与设计稿相差太多,且此方案不会影响到三方库。缺点:需要生成多套 dimens 文件,增大了 apk 体积


需要强调下,以上三种方案其实都存在一个问题:我们只能实现对单个方向的适配,无法同时兼顾宽高。之所以只能单个方向,是因为当前手机屏幕的宽高比并不是按照一个固定的比例进行递增的,4 : 3、16 : 9、甚至其它宽高比都有,这种背景下我们要达到百分百还原设计稿是不现实的,我们只能选择一个维度来进行适配。幸运的是大部分情况下我们也只需要根据屏幕宽度来进行适配,以上方案已经能够满足我们绝大多数时候的开发需求了。对于少部分需要根据高度进行适配的页面,今日头条方案可以很灵活的进行切换,smallestWidth 方案就比较麻烦了,此时可以通过 ConstraintLayout 来精准按比例控制控件的宽高大小或者是位置,同样也能达到适配要求 ~


此外,我看到网络上很多开发者都在说 dpi 的存在就是为了让大屏幕手机能够显示更多内容,屏幕适配导致 dpi 失去了其原有的意义,但我其实并不理解这和屏幕适配有什么关系。现在的现实背景就是存在某些屏幕像素宽度相同的手机,其 dpi 却不一样,如果单纯直接使用 dp 而不进行额外适配的话,那在这类机型下控件就会相比设计稿多出一些空白或者是超出屏幕范围,这是开发者不得不解决的问题。如果说显示更多内容指的是让控件在大屏幕手机上能够占据更多的物理空间,那么前提也是要让各个控件的大小和位置都符合设计稿的要求,屏幕适配要做到的就是这一点,同等比例下控件在大屏幕手机上自然就会有更多物理空间。而如果说显示更多内容指的是当在大屏幕手机上有剩余空间时就相比小屏幕多显示其它控件,那么我觉得不仅开发要疯,设计师都要疯……


最后,这里再提供一份用于生成 dimens 文件的代码,基于 smallestWidth 方案,代码总的不到一百行,实现思路在前文讲的很清楚了。仅需要填入设计稿的宽高像素大小就可以,默认基于 1080 x 1920 px 的设计稿,生成范围从 320 到 460 dp 之间,步长 10 dp,读者可以按需调整



有需要的同学自取:SmallestWidthGenerator

收起阅读 »

基于环信MQTT消息云,Android端快速实现消息收发

本文介绍Android端如何连接环信MQTT消息云快速实现消息的自收自发。一、前提条件1、部署Android开发环境下载安装Android studio,配置好开发环境2、导入项目依赖在项目根目录build.gradle文件里配置repositories { ...
继续阅读 »

本文介绍Android端如何连接环信MQTT消息云快速实现消息的自收自发。

一、前提条件

1、部署Android开发环境

下载安装Android studio,配置好开发环境

2、导入项目依赖

在项目根目录build.gradle文件里配置
repositories {
maven {
url "https://repo.eclipse.org/content/repositories/paho-snapshots/"
}
}
另需要在app的build.gradle里添加依赖
dependencies {
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.1.0'
implementation 'org.eclipse.paho:org.eclipse.paho.android.service:1.1.1'
}

二、实现流程

1、获取鉴权

首先需要登录环信云console控制台,获取到AppID、连接地址、连接端口,然后代码实现获取token

客户端获取token代码实例如下:

//使用okhttp实现的获取token
JSONObject reqBody = new JSONObject();
reqBody.put("grant_type", "password");
reqBody.put("username", "hxtest");
reqBody.put("password", "1");
OkHttpClient okHttpClient = new OkHttpClient.Builder().build();
MediaType mediaType = MediaType.parse("application/json");
RequestBody requestBody = RequestBody.create(mediaType, reqBody.toString());
Request request = new Request.Builder()
.url("https://{token域名}/{org_name}/{app_name}/token")
.post(requestBody)
.build();
Call call = okHttpClient.newCall(request);
call.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "okhttp_onFailure:" + e.getMessage());
}

@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = response.body().string();
if (response.code() == 200) {
try {
JSONObject result = new JSONObject(responseBody);
String token = result.getString("access_token");
} catch (JSONException e) {
e.printStackTrace();
}
}
}
});



2、初始化
在项目中创建MQTT客户端,客户端初始配置包括创建clientID,topic名称,QoS质量,连接地址等信息。

//连接时使用的clientId, 必须唯一
String clientId = String.format("%s@%s", userName, appId);
MqttAndroidClient mMqttClient = new MqttAndroidClient(context, String.format("tcp://%s:%s", mqttUri, mqttPort), clientId);


3、连接服务器

调用connect()函数连接至环信MQTT消息云

//连接参数
MqttConnectOptions options;
options = new MqttConnectOptions();
//设置自动重连
options.setAutomaticReconnect(true);
// 缓存
options.setCleanSession(true);
// 设置超时时间,单位:秒
options.setConnectionTimeout(15);
// 心跳包发送间隔,单位:秒
options.setKeepAliveInterval(15);
// 用户名
options.setUserName(userName);
// 密码
options.setPassword(token.toCharArray());
options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1);
//进行连接
mMqttClient.connect(options, null, new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {

}

@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {

}
});


4、订阅

【订阅主题】当客户端成功连接环信MQTT消息云后,需尽快向服务器发送订阅主题消息。

try {
//连接成功后订阅主题
mMqttClient.subscribe(topic, qos, null, new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {

}

@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {

}
});
} catch (MqttException e) {
e.printStackTrace();
}

【取消订阅】

try {
mMqttClient.unsubscribe(topic);
} catch (MqttException e) {
e.printStackTrace();
}


5、收发消息

【发送消息】配置发送消息回调方法,向环信MQTT消息云中指定topic发送消息。

MqttMessage msg=new MqttMessage();
msg.setPayload(content.getBytes());//设置消息内容
msg.setQos(qos);//设置消息发送质量,可为0,1,2.
//设置消息的topic,并发送。
mMqttClient.publish(topic, msg, null, new IMqttActionListener() {
@Override
public void onSuccess(IMqttToken asyncActionToken) {
Log.d(TAG, "onSuccess: 发送成功");
}

@Override
public void onFailure(IMqttToken asyncActionToken, Throwable exception) {
Log.d(TAG, "onFailure: 发送失败="+ exception.getMessage());
}
});

【接收消息】配置接收消息回调方法,从环信MQTT消息云接收订阅消息。

// 设置MQTT监听
mMqttClient.setCallback(new MqttCallback() {
@Override
public void connectionLost(Throwable cause) {
Log.d(TAG, "connectionLost: 连接断开");
}

@Override
public void messageArrived(String topic, MqttMessage message) throws Exception {
Log.d(TAG, "收到消息:"+message.toString());
}

@Override
public void deliveryComplete(IMqttDeliveryToken token) {

}
});

三、更多信息

github地址: https://github.com/wangxinjeff/mqttdemo-android




收起阅读 »

(算法入门)人人都能看懂的时间复杂度和空间复杂度

你是怎么理解算法的呢? 简单说就是,同一个功能 别人写的代码跑起来占内存 100M,耗时 100 毫秒 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多 所以 衡量代码好坏有两个非常重要的标准就是:运行时间和占用空间,就是我们后面要说到的...
继续阅读 »

你是怎么理解算法的呢?


简单说就是,同一个功能



  • 别人写的代码跑起来占内存 100M,耗时 100 毫秒

  • 你写的代码跑起来占内存 500M,耗时 1000 毫秒,甚至更多


所以



  1. 衡量代码好坏有两个非常重要的标准就是:运行时间占用空间,就是我们后面要说到的时间复杂度空间复杂度也是学好算法的重要基石

  2. 这也是会算法和不会算法的攻城狮的区别、更是薪资的区别,因为待遇好的大厂面试基本都有算法


可能有人会问:别人是怎么做到的?代码没开发完 运行起来之前怎么知道占多少内存和运行时间呢?


确切的占内用存或运行时间确实算不出来,而且同一段代码在不同性能的机器上执行的时间也不一样,可是代码的基本执行次数,我们是可以算得出来的,这就要说到时间复杂度了


什么是时间复杂度


看个栗子


function foo1(){
console.log("我吃了一颗糖")
console.log("我又吃了一颗糖")
return "再吃一颗糖"
}

调用这个函数,里面总执行次数就是3次,这个没毛病,都不用算


那么下面这个栗子呢


function foo2(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
return "一颗糖"
}

那这个函数里面总执行次数呢?根据我们传进去的值不一样,执行次数也就不一样,但是大概次数我们总能知道


let = 0               :执行 1 次
i < n : 执行 n+1 次
i++ : 执行 n+1 次
console.log("执行了") : 执行 n 次
return 1 : 执行 1 次

这个函数的总执行次数就是 3n + 4 次,对吧


可是我们开发不可能都这样去数,所以根据代码执行时间的推导过程就有一个规律,也就是所有代码执行时间 T(n)和代码的执行次数 f(n) ,这个是成正比的,而这个规律有一个公式



T(n) = O( f(n) )



n 是输入数据的大小或者输入数据的数量  
T(n) 表示一段代码的总执行时间
f(n) 表示一段代码的总执行次数
O 表示代码的执行时间 T(n) 和 执行次数f(n) 成正比

完整的公式看着就很麻烦,别着急,这个公式只要了解一下就可以了,为的就是让你知道我们表示算法复杂度的 O() 是怎么来的,我们平时表示算法复杂度主要就是用 O(),读作大欧表示法,是字母O不是零


只用一个 O() 表示,这样看起来立马就容易理解多了


回到刚才的两个例子,就是上面的两个函数



  • 第一个函数执行了3次,用复杂度表示就是 O(3)

  • 第二个函数执行了3n + 4次,复杂度就是 O(3n+4)


这样有没有觉得还是很麻烦,因为如果函数逻辑一样的,只是执行次数差个几次,像O(3) 和 O(4),有什么差别?还要写成两种就有点多此一举了,所以复杂度里有统一的简化的表示法,这个执行时间简化的估算值就是我们最终的时间复杂度


简化的过程如下



  • 如果只是常数直接估算为1,O(3) 的时间复杂度就是 O(1),不是说只执行了1次,而是对常量级时间复杂度的一种表示法。一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是O(1)

  • O(3n+4) 里常数4对于总执行次数的几乎没有影响,直接忽略不计,系数 3 影响也不大,因为3n和n都是一个量级的,所以作为系数的常数3也估算为1或者可以理解为去掉系数,所以 O(3n+4) 的时间复杂度为 O(n)

  • 如果是多项式,只需要保留n的最高次项O( 666n³ + 666n² + n ),这个复杂度里面的最高次项是n的3次方。因为随着n的增大,后面的项的增长远远不及n的最高次项大,所以低于这个次项的直接忽略不计,常数也忽略不计,简化后的时间复杂度为 O(n³)


这里如果没有理解的话,暂停理解一下


接下来结合栗子,看一下常见的时间复杂度


常用时间复杂度


O(1)


上面说了,一般情况下,只要算法里没有循环和递归,就算有上万行代码,时间复杂度也是 O(1),因为它的执行次数不会随着任何一个变量的增大而变长,比如下面这样


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


上面也介绍了 O(n),总的来说 只有一层循环或者递归等,时间复杂度就是 O(n),比如下面这样


function foo1(n){
for( let i = 0; i < n; i++){
console.log("我吃了一颗糖")
}
}
function foo2(n){
while( --n > 0){
console.log("我吃了一颗糖")
}
}
function foo3(n){
console.log("我吃了一颗糖")
--n > 0 && foo3(n)
}

O(n²)


比如嵌套循环,如下面这样的,里层循环执行 n 次,外层循环也执行 n 次,总执行次数就是 n x n,时间复杂度就是 n 的平方,也就是 O(n²)。假设 n 是 10,那么里面的就会打印 10 x 10 = 100 次


function foo1(n){
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

还有这样的,总执行次数为 n + n²,上面说了,如果是多项式,取最高次项,所以这个时间复杂度也是 O(n²)


function foo2(n){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}

//或者下面这样,以运行时间最长的,作为时间复杂度的依据,所以下面的时间复杂度就是 O(n²)
function foo3(n){
if( n > 100){
for( let k = 0; k < n; k++){
console.log("我吃了一颗糖")
}
}else{
for( let i = 0; i < n; i++){
for( let j = 0; j < n; j++){
console.log("我吃了一颗糖")
}
}
}
}

O(logn)


举个栗子,这里有一包糖


asdf.jpeg


这包糖里有16颗,沐华每天吃这一包糖的一半,请问多少天吃完?


意思就是16不断除以2,除几次之后等于1?用代码表示


function foo1(n){
let day = 0
while(n > 1){
n = n/2
day++
}
return day
}
console.log( foo1(16) ) // 4

循环次数的影响主要来源于 n/2 ,这个时间复杂度就是 O(logn) ,这个复杂度是怎么来的呢,别着急,继续看


再比如下面这样


function foo2(n){
for(let i = 0; i < n; i *= 2){
console.log("一天")
}
}
foo2( 16 )

里面的打印执行了 4 次,循环次数主要影响来源于 i *= 2 ,这个时间复杂度也是 O(logn)


这个 O(logn) 是怎么来的,这里补充一个小学三年级数学的知识点,对数,我们看一张图


未标题-1.jpg


没有理解的话再看一下,理解一下规律



  • 真数:就是真数,这道题里就是16

  • 底数:就是值变化的规律,比如每次循环都是i*=2,这个乘以2就是规律。比如1,2,3,4,5...这样的值的话,底就是1,每个数变化的规律是+1嘛

  • 对数:在这道题里可以理解成x2乘了多少次,这个次数


仔细观察规律就会发现这道题里底数是 2,而我们要求的天数就是这个对数4,在对数里有一个表达公式



ab = n  读作以a为底,b的对数=n,在这道题里我们知道a和n的值,也就是  2b = 16 然后求 b



把这个公式转换一下的写法如下



logan = b    在这道题里就是   log216 = ?  答案就是 4



公式是固定的,这个16不是固定的,是我们传进去的 n,所以可以理解为这道题就是求 log2n = ?


用时间复杂度表示就是 O(log2n),由于时间复杂度需要去掉常数和系数,而log的底数跟系数是一样的,所以也需要去掉,所以最后这个正确的时间复杂度就是 O(logn)


emmmmm.....


没有理解的话,可以暂停理解一下


其他还有一些时间复杂度,我由快到慢排列了一下,如下表顺序



这些时间复杂度有什么区别呢,看张图


未标题-3.jpg


随着数据量或者 n 的增大,时间复杂度也随之增加,也就是执行时间的增加,会越来越慢,越来越卡


总的来说时间复杂度就是执行时间增长的趋势,那么空间复杂度就是存储空间增长的趋势


什么是空间复杂度


空间复杂度就是算法需要多少内存,占用了多少空间


常用的空间复杂度有 O(1)O(n)O(n²)


O(1)


只要不会因为算法里的执行,导致额外的空间增长,就算是一万行,空间复杂度也是 O(1),比如下面这样,时间复杂度也是 O(1)


function foo(){
let n = 1
let b = n * 100
if(b === 100){
console.log("开始吃糖")
}
console.log("我吃了1颗糖")
console.log("我吃了2颗糖")
......
console.log("我吃了10000颗糖")
}

O(n)


比如下面这样,n 的数值越大,算法需要分配的空间就需要越多,来存储数组里的值,所以它的空间复杂度就是 O(n),时间复杂度也是 O(n)


function foo(n){
let arr = []
for( let i = 1; i < n; i++ ) {
arr[i] = i
}
}

O(n²)


O(n²) 这种空间复杂度一般出现在比如二维数组,或是矩阵的情况下


不用说,你肯定明白是啥情况啦


就是遍历生成类似这样格式的


let arr = [
[1,2,3,4,5],
[1,2,3,4,5],
[1,2,3,4,5]
]

结语


希望本文对你有一点点帮助,另外,求个赞,谢谢! ^_^


想要学好算法,就必须要理解复杂度这个重要基石


复杂度分析不难,关键还是在于多练。每次看到代码的时候,简单的一眼就能看出复杂度,难的稍微分析一下也能得出答案。推荐去 leetCode 刷题哦,App或者PC端都可以


链接:https://juejin.cn/post/6999307229752983582

收起阅读 »

什么?数学不好人都不配写CSS?

前言 大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。 之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计...
继续阅读 »

前言


大家好,这里是 CSS 兼 WebGL 魔法使——alphardex。


之前一直在玩 three.js ,接触了很多数学函数,用它们创造过很多特效。于是我思考:能否在 CSS 中也用上这些数学函数,但发现 CSS 目前还没有,据说以后的新规范会纳入,估计也要等很久。


然而,我们可以通过一些小技巧,来创作出一些属于自己的 CSS 数学函数,从而实现一些有趣的动画效果。


让我们开始吧!



CSS 数学函数


注意:以下的函数用原生 CSS 也都能实现,这里用 SCSS 函数只是为了方便封装,封装起来的话更方便调用


绝对值


绝对值就是正的还是正的,负的变为正的


可以创造 2 个数,其中一个数是另一个数的相反数,比较它们的最大值,即可获得这个数的绝对值


@function abs($v) {
@return max(#{$v}, calc(-1 * #{$v}));
}

中位数


原数减 1 并乘以一半即可


@function middle($v) {
@return calc(0.5 * (#{$v} - 1));
}

数轴上两点距离


数轴上两点距离就是两点所表示数字之差的绝对值,有了上面的绝对值公式就可以直接写出来


@function dist-1d($v1, $v2) {
$v-delta: calc(#{$v1} - #{$v2});
@return #{abs($v-delta)};
}

三角函数


其实这个笔者也不会实现~不过之前看到过好友 chokcoco 的一篇文章写到了如何在 CSS 中实现三角函数,在此表示感谢


@function fact($number) {
$value: 1;
@if $number>0 {
@for $i from 1 through $number {
$value: $value * $i;
}
}
@return $value;
}

@function pow($number, $exp) {
$value: 1;
@if $exp>0 {
@for $i from 1 through $exp {
$value: $value * $number;
}
} @else if $exp < 0 {
@for $i from 1 through -$exp {
$value: $value / $number;
}
}
@return $value;
}

@function rad($angle) {
$unit: unit($angle);
$unitless: $angle / ($angle * 0 + 1);
@if $unit==deg {
$unitless: $unitless / 180 * pi();
}
@return $unitless;
}

@function pi() {
@return 3.14159265359;
}

@function sin($angle) {
$sin: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$sin: $sin + pow(-1, $i) * pow($angle, (2 * $i + 1)) / fact(2 * $i + 1);
}
@return $sin;
}

@function cos($angle) {
$cos: 0;
$angle: rad($angle);
// Iterate a bunch of times.
@for $i from 0 through 20 {
$cos: $cos + pow(-1, $i) * pow($angle, 2 * $i) / fact(2 * $i);
}
@return $cos;
}

@function tan($angle) {
@return sin($angle) / cos($angle);
}

例子


以下的几个动画特效演示了上面数学函数的作用


一维交错动画


初始状态


创建一排元素,用内部阴影填充,准备好我们的数学函数


<div class="list">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #222;
}

:root {
--blue-color-1: #6ee1f5;
}

(这里复制粘贴上文所有的数学公式)

.list {
--n: 16;

display: flex;
flex-wrap: wrap;
justify-content: space-evenly;

&-item {
--p: 2vw;
--gap: 1vw;
--bg: var(--blue-color-1);

@for $i from 1 through 16 {
&:nth-child(#{$i}) {
--i: #{$i};
}
}

padding: var(--p);
margin: var(--gap);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fb7wZV.png


应用动画


这里用了 2 个动画:grow 负责将元素缩放出来;melt 负责“融化”元素(即消除阴影的扩散半径)


<div class="list grow-melt">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.grow-melt {
.list-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

@keyframes grow {
0% {
transform: scale(0);
}

50%,
100% {
transform: scale(1);
}
}

@keyframes melt {
0%,
50% {
box-shadow: inset 0 0 0 var(--p) var(--bg);
}

100% {
box-shadow: inset 0 0 0 0 var(--bg);
}
}

fqkIkF.gif


交错动画



  1. 计算出元素下标的中位数

  2. 计算每个元素 id 到这个中位数的距离

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="list grow-melt middle-stagger">
<div class="list-item"></div>
...(此处省略14个 list-item)
<div class="list-item"></div>
</div>

.list {
&.middle-stagger {
.list-item {
--m: #{middle(var(--n))}; // 中位数,这里是7.5
--i-m-dist: #{dist-1d(var(--i), var(--m))}; // 计算每个id到中位数之间的距离
--ratio: calc(var(--i-m-dist) / var(--m)); // 根据距离算出比例
--delay: calc(var(--ratio) * var(--t)); // 根据比例算出delay
--n-delay: calc((var(--ratio) - 2) * var(--t)); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fqkzkD.gif


地址:Symmetric Line Animation


二维交错动画


初始状态


如何将一维的升成二维?应用网格系统即可


<div class="grid">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
$row: 8;
$col: 8;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

display: grid;
gap: var(--gap);
grid-template-rows: repeat(var(--row), 1fr);
grid-template-columns: repeat(var(--col), 1fr);

&-item {
--p: 2vw;
--bg: var(--blue-color-1);

@for $y from 1 through $row {
@for $x from 1 through $col {
$k: $col * ($y - 1) + $x;
&:nth-child(#{$k}) {
--x: #{$x};
--y: #{$y};
}
}
}

padding: var(--p);
box-shadow: inset 0 0 0 var(--p) var(--bg);
}
}

fLsvPx.png


应用动画


跟上面的动画一模一样


<div class="grid grow-melt">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.grow-melt {
.grid-item {
--t: 2s;

animation-name: grow, melt;
animation-duration: var(--t);
animation-iteration-count: infinite;
}
}
}

fLsGvD.gif


交错动画



  1. 计算出网格行列的中位数

  2. 计算网格 xy 坐标到中位数的距离并求和

  3. 根据距离算出比例

  4. 根据比例算出 delay


<div class="grid grow-melt middle-stagger">
<div class="grid-item"></div>
...(此处省略62个 grid-item)
<div class="grid-item"></div>
</div>

.grid {
&.middle-stagger {
.grid-item {
--m: #{middle(var(--col))}; // 中位数,这里是7.5
--x-m-dist: #{dist-1d(var(--x), var(--m))}; // 计算x坐标到中位数之间的距离
--y-m-dist: #{dist-1d(var(--y), var(--m))}; // 计算y坐标到中位数之间的距离
--dist-sum: calc(var(--x-m-dist) + var(--y-m-dist)); // 距离之和
--ratio: calc(var(--dist-sum) / var(--m)); // 根据距离和计算比例
--delay: calc(var(--ratio) * var(--t) * 0.5); // 根据比例算出delay
--n-delay: calc(
(var(--ratio) - 2) * var(--t) * 0.5
); // 负delay表示动画提前开始

animation-delay: var(--n-delay);
}
}
}

fL2Ppt.gif


地址:Symmetric Grid Animation


另一种动画


可以换一种动画 shuffle(穿梭),会产生另一种奇特的效果


<div class="grid shuffle middle-stagger">
<div class="grid-item"></div>
...(此处省略254个 grid-item )
<div class="grid-item"></div>
</div>

.grid {
$row: 16;
$col: 16;
--row: #{$row};
--col: #{$col};
--gap: 0.25vw;

&-item {
--p: 1vw;

transform-origin: bottom;
transform: scaleY(0.1);
}

&.shuffle {
.grid-item {
--t: 2s;

animation: shuffle var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes shuffle {
0% {
transform: scaleY(0.1);
}

50% {
transform: scaleY(1);
transform-origin: bottom;
}

50.01% {
transform-origin: top;
}

100% {
transform-origin: top;
transform: scaleY(0.1);
}
}

fOJSZ8.gif


地址:Shuffle Grid Animation


余弦波动动画


初始状态


创建 7 个不同颜色的(这里直接选了彩虹色)列表,每个列表有 40 个子元素,每个子元素是一个小圆点


让这 7 个列表排列在一条线上,且 z 轴上距离错开,设置好基本的 delay


<div class="lists">
<div class="list">
<div class="list-item"></div>
...(此处省略39个 list-item)
</div>
...(此处省略6个 list)
</div>

.lists {
$list-count: 7;
$colors: red, orange, yellow, green, cyan, blue, purple;

position: relative;
width: 34vw;
height: 2vw;
transform-style: preserve-3d;
perspective: 800px;

.list {
position: absolute;
top: 0;
left: 0;
display: flex;
transform: translateZ(var(--z));

@for $i from 1 through $list-count {
&:nth-child(#{$i}) {
--bg: #{nth($colors, $i)};
--z: #{$i * -1vw};
--basic-delay-ratio: #{$i / $list-count};
}
}

&-item {
--w: 0.6vw;
--gap: 0.15vw;

width: var(--w);
height: var(--w);
margin: var(--gap);
background: var(--bg);
border-radius: 50%;
}
}
}

hSdtfI.png


余弦排列


运用上文的三角函数公式,让这些小圆点以余弦的一部分形状进行排列


.lists {
.list {
&-item {
$item-count: 40;
$offset: pi() * 0.5;
--wave-length: 21vw;

@for $i from 1 through $item-count {
&:nth-child(#{$i}) {
--i: #{$i};
$ratio: ($i - 1) / ($item-count - 1);
$angle-unit: pi() * $ratio;
$wave: cos($angle-unit + $offset);
--single-wave-length: calc(#{$wave} * var(--wave-length));
--n-single-wave-length: calc(var(--single-wave-length) * -1);
}
}

transform: translateY(var(--n-single-wave-length));
}
}
}

hSwuNj.png


波动动画


对每个小圆点应用上下平移动画,平移的距离就是余弦的波动距离


.lists {
.list {
&-item {
--t: 2s;

animation: wave var(--t) infinite ease-in-out alternate;
}
}
}

@keyframes wave {
from {
transform: translateY(var(--n-single-wave-length));
}

to {
transform: translateY(var(--single-wave-length));
}
}

hSwfPA.gif


交错动画


跟上面一个套路,计算从中间开始的 delay,再应用到动画上即可


.lists {
.list {
&-item {
--n: #{$item-count + 1};
--m: #{middle(var(--n))};
--i-m-dist: #{dist-1d(var(--i), var(--m))};
--ratio: calc(var(--i-m-dist) / var(--m));
--square: calc(var(--ratio) * var(--ratio));
--delay: calc(
calc(var(--square) + var(--basic-delay-ratio) + 1) * var(--t)
);
--n-delay: calc(var(--delay) * -1);

animation-delay: var(--n-delay);
}
}
}

hSwqaQ.gif


地址:Rainbow Sine


最后


CSS 数学函数能实现的特效远不止于此,希望通过本文能激起大家创作特效的灵感~


作者:alphardex
链接:https://juejin.cn/post/6999416290997698596

收起阅读 »

聊一聊移动端适配

一、引言 用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子…. 充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具...
继续阅读 »

一、引言



用户选择大屏幕有两个几个出发点,有些人想要更大的字体,更大的图片;有些人想要更多的内容,并不想要更大的图标;有些人想要个镜子….



充分了解各种设备,我们会知道不同尺寸的屏幕本身就有各自的定位,像ipad类的大屏设备本身相比较iphone5就应该具有更大的视野,而不是粗暴的让用户去感受老人机的体验。


但由于设计及开发资源的紧张,现阶段只能将一套设计稿应用在多尺寸设备上,因此我们需要考虑在保持一套设计稿的方案下如何使展示更加合理。


二、基本单位


对于移动端开发而言,为了做到页面高清的效果,视觉稿的规范往往会遵循以下两点:



  1. 首先,选取一款手机的屏幕宽高作为基准(以前是iphone4的320×480,现在更多的是iphone6的375×667)。

  2. 对于retina屏幕(如: dpr=2),为了达到高清效果,视觉稿的画布大小会是基准的2倍,也就是说像素点个数是原来的4倍(对iphone6而言:原先的375×667,就会变成750×1334)。


物理像素(physical pixel)


一个物理像素是显示器(手机屏幕)上最小的物理显示单元,在操作系统的调度下,每一个设备像素都有自己的颜色值和亮度值。


设备独立像素(density-independent pixel)


设备独立像素(也叫密度无关像素),可以认为是计算机坐标系统中得一个点,这个点代表一个可以由程序使用的虚拟像素(比如: css像素),然后由相关系统转换为物理像素。


所以说,物理像素和设备独立像素之间存在着一定的对应关系,这就是接下来要说的设备像素比。


DPR 设备像素比(device pixel ratio )


设备像素比 = 物理像素 / 设备独立像素; // 在某一方向上,x方向或者y方向
可以在JS中 window.devicePixelRatio获取到当前设备的dpr


三、常见的布局类型


rem 布局


原理: 根据手机的屏幕尺寸 和dpr,动态修改html的基准值(font-size)


公式: rem = document.documentElement.clientWidth * dpr / 100


注释: 乘以dpr,是因为页面有可能为了实现1px border页面会缩放(scale) 1/dpr 倍(如果没有,dpr=1)


假设我们将屏幕宽度平均分成100份,每一份的宽度用per表示,per = 屏幕宽度 / 100,如果将per作为单位,per前面的数值就代表屏幕宽度的百分比


p {width: 50per;} /* 屏幕宽度的50% */

如果想要页面元素随着屏幕宽度等比变化,我们需要上面的per单位,如果子元素设置rem单位的属性,通过更改html元素的字体大小,就可以让子元素实际大小发生变化


html {font-size: 16px}
p {width: 2rem} /* 32px*/

html {font-size: 32px}
p {width: 2rem} /*64px*/

如果让html元素字体的大小,恒等于屏幕宽度的1/100,那1rem和1per就等价了


html {fons-size: 元素宽度 / 100}
p {width: 50rem} /* 50rem = 50per = 屏幕宽度的50% */

实际应用



rem作用于非根元素时,相对于根元素字体大小;rem作用于根元素字体大小时,相对于其初始字体大小



可以看出 rem 取值分为两种情况,设置在根元素时和非根元素时,举个例子:


/* 作用于根元素,相对于原始大小(16px),所以html的font-size为32px*/
html {font-size: 2rem}

/* 作用于非根元素,相对于根元素字体大小,所以为64px */
p {font-size: 2rem}

举个例子:
























vw


vw/vh是基于 Viewport 视窗的长度单位window.innerWidth/window.innerHeight
在CSS Values and Units Module Level 3中和Viewport相关的单位有四个,分别为vwvhvminvmax



  • vw:是Viewport’s width的简写, 1vw等于window.innerWidth的1%

  • vh:和vw类似,是Viewport’s height的简写,1vh等于window.innerHeihgt的1%\

  • vmin:vmin的值是当前vw和vh中较小的值

  • vmax:vmax的值是当前vw和vh中较大的值


image.png
可以看到vw其实是实现了1vw = 1per,比起rem需要计算html的基准值,vw无疑更加方便。


/* rem方案 */
html {fons-size: width / 100}
p {width: 15.625rem}

/* vw方案 */
p {width: 15.625vw}

Q:vw如此方便,是不是就比rem更好,可以完全取代rem了呢?


A:当然不是。


vw也有缺点。



  • vw换算有时并不精确,较小的像素不好适配,就像我们可以用较小值精确地表示较大值,用较大值表示较小值就可能存在数位换算等问题而无法精确表示。

  • vw的兼容性不如rem

  • 使用弹性布局时,vw无法限制最大宽度。rem可以通过控制HTML基准值,来实现最大宽度的限制。


Q:rem就如此完美吗?


A:rem也并不是万能的



  • rem的制作成本更大,需要使用额外的插件去实现。

  • 字体不能用rem,字体大小和字体宽度不成线性关系,所有字体大小不能使用rem,由于设置了根元素字体的大小,会影响所有没有设置字体的元素,因此需要设置所有需要字体控制的元素。

  • 从用户体验上来看,文字阅读的舒适度跟媒体介质大小是没关系的。


四、适配方案


方案一: rem/vw


适用场景:



  • 对视觉组件种类较多,视觉设计对元素位置的相对关系依赖较强的移动端页面:vw/rem


示例:



  • 饿了么(h5.ele.me/msite/)

  • 对viewport进行了缩放

  • html元素的font-size依然由px指定

  • 具体元素的布局上使用vw + rem fallbak的形式

  • 没有限制布局宽度

  • css构建过程需要插件支持


方案二: flex + px + 百分比


适用场景:



  • 追求阅读体验的场景,如列表页。


示例:





作者:_Battle
链接:https://juejin.cn/post/6999438892441026591

收起阅读 »

8个工程必备的JavaScript代码片段(建议添加到项目中)

1. 获取文件后缀名 使用场景:上传文件判断后缀名 /** * 获取文件后缀名 * @param {String} filename */ export function getExt(filename) { if (typeof filena...
继续阅读 »

1. 获取文件后缀名


使用场景:上传文件判断后缀名


/**
* 获取文件后缀名
* @param {String} filename
*/
export function getExt(filename) {
if (typeof filename == 'string') {
return filename
.split('.')
.pop()
.toLowerCase()
} else {
throw new Error('filename must be a string type')
}
}

使用方式


getExt("1.mp4") //->mp4

2. 复制内容到剪贴板


export function copyToBoard(value) {
const element = document.createElement('textarea')
document.body.appendChild(element)
element.value = value
element.select()
if (document.execCommand('copy')) {
document.execCommand('copy')
document.body.removeChild(element)
return true
}
document.body.removeChild(element)
return false
}


使用方式:


//如果复制成功返回true
copyToBoard('lalallala')

原理:



  1. 创建一个textare元素并调用select()方法选中

  2. document.execCommand('copy')方法,拷贝当前选中内容到剪贴板。


3. 休眠多少毫秒


/**
* 休眠xxxms
* @param {Number} milliseconds
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

//使用方式
const fetchData=async()=>{
await sleep(1000)
}

4. 生成随机字符串


/**
* 生成随机id
* @param {*} length
* @param {*} chars
*/
export function uuid(length, chars) {
chars =
chars ||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
length = length || 8
var result = ''
for (var i = length; i > 0; --i)
result += chars[Math.floor(Math.random() * chars.length)]
return result
}

使用方式


//第一个参数指定位数,第二个字符串指定字符,都是可选参数,如果都不传,默认生成8位
uuid()

使用场景:用于前端生成随机的ID,毕竟现在的Vue和React都需要绑定key


5. 简单的深拷贝


/**
*深拷贝
* @export
* @param {*} obj
* @returns
*/
export function deepCopy(obj) {
if (typeof obj != 'object') {
return obj
}
if (obj == null) {
return obj
}
return JSON.parse(JSON.stringify(obj))
}

缺陷:只拷贝对象、数组以及对象数组,对于大部分场景已经足够


const person={name:'xiaoming',child:{name:'Jack'}}
deepCopy(person) //new person

6. 数组去重


/**
* 数组去重
* @param {*} arr
*/
export function uniqueArray(arr) {
if (!Array.isArray(arr)) {
throw new Error('The first parameter must be an array')
}
if (arr.length == 1) {
return arr
}
return [...new Set(arr)]
}

原理是利用Set中不能出现重复元素的特性


uniqueArray([1,1,1,1,1])//[1]

7. 对象转化为FormData对象


/**
* 对象转化为formdata
* @param {Object} object
*/

export function getFormData(object) {
const formData = new FormData()
Object.keys(object).forEach(key => {
const value = object[key]
if (Array.isArray(value)) {
value.forEach((subValue, i) =>
formData.append(key + `[${i}]`, subValue)
)
} else {
formData.append(key, object[key])
}
})
return formData
}

使用场景:上传文件时我们要新建一个FormData对象,然后有多少个参数就append多少次,使用该函数可以简化逻辑


使用方式:


let req={
file:xxx,
userId:1,
phone:'15198763636',
//...
}
fetch(getFormData(req))

8.保留到小数点以后n位


// 保留小数点以后几位,默认2位
export function cutNumber(number, no = 2) {
if (typeof number != 'number') {
number = Number(number)
}
return Number(number.toFixed(no))
}

使用场景:JS的浮点数超长,有时候页面显示时需要保留2位小数


作者:_红领巾
链接:https://juejin.cn/post/6999391770672889893

收起阅读 »

前端工程化实战 - 可配置的模板管理

功能设计 如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。 其次,对于业务开发同学来说,...
继续阅读 »

功能设计


如果每一次新脚手架的开发或者模板的更新都需要重新更新一次 CLI 的话,虽然成本不高,但是开发模板的同学需要通知 CLI 开发的同学去升级,使用模板的同学又需要在等 CLI 开发完毕才能使用,中间交流沟通的成本就增加了。


其次,对于业务开发同学来说,可能只需要一类或者几类的模板,那么如果 CLI 是一个大而全的模板集合,对这些同学来说,快速选择模板创建项目反而也是一个负担,因为要在很多模板中选择自己想要的也是很花费时间。


所以我们的目的是设计一款拥有自定义配置与可升级模板功能的 CLI 工具。


未命名文件.png


既然是自定义配置,那么就需要用户可以在本地手动添加、删除、更新自己常用的模板信息,同时需要可以动态的拉取这些模板而不是一直下载下来就是本地的旧版本。


根据需求,可简单设计一下我们 CLI 的模板功能概要:



  1. 需要保存模板来源的地址

  2. 根据用户的选择拉取不同的模板代码

  3. 将模板保存在本地


实战开发


那么根据上面的设计思路,我们可以一步步开发所需要的功能


本地保存模板地址功能


第一步,如果需要将模板的一些信息保存在本地的话,我们需要一个对话型的交互,引导用户输入我们需要的信息,所以可以选择 inquirer 这个工具库。



Inquirerjs 是一个用来实现命令行交互式界面的工具集合。它帮助我们实现与用户的交互式交流,比如给用户提一个问题,用户给我们一个答案,我们根据用户的答案来做一些事情,典型应用如 plop等生成器工具。



一般拉取代码的话,我们需要知道用户输入的模板地址(通过 URL 拉取对应模板的必须条件)、模板别名(方便用户做搜索)、模板描述(方便用户了解模板信息)


这样需要保存的模板信息有地址、别名与描述,后续可以方便我们去管理对应的模板。示例代码如下:


import inquirer from 'inquirer';
import { addTpl } from '@/tpl'

const promptList = [
{
type: 'input',
message: '请输入仓库地址:',
name: 'tplUrl',
default: 'https://github.com/boty-design/react-tpl'
},
{
type: 'input',
message: '模板标题(默认为 Git 名作为标题):',
name: 'name',
default({ tplUrl }: { tplUrl: string }) {
return tplUrl.substring(tplUrl.lastIndexOf('/') + 1)
}
},
{
type: 'input',
message: '描述:',
name: 'desc',
}
];

export default () => {
inquirer.prompt(promptList).then((answers: any) => {
const { tplUrl, name, desc } = answers
addTpl(tplUrl, name, desc)
})
}
复制代码

通过 inquirer 已经拿到了对应的信息,但由于会有电脑重启等各种情况发生,所以数据存在缓存中是不方便的,这种 CLI 工具如果使用数据库来存储也是大材小用,所以可以将信息直接已经以 json 文件的方式存储在本地。


示例代码如下:


import { loggerError, loggerSuccess, getDirPath } from '@/util'
import { loadFile, writeFile } from '@/util/file'

interface ITpl {
tplUrl: string
name: string
desc: string
}

const addTpl = async (tplUrl: string, name: string, desc: string) => {
const cacheTpl = getDirPath('../cacheTpl')
try {
const tplConfig = loadFile<ITpl[]>(`${cacheTpl}/.tpl.json`)
let file = [{
tplUrl,
name,
desc
}]
if (tplConfig) {
const isExist = tplConfig.some(tpl => tpl.name === name)
if (isExist) {
file = tplConfig.map(tpl => {
if (tpl.name === name) {
return {
tplUrl,
name,
desc
}
}
return tpl
})
} else {
file = [
...tplConfig,
...file
]
}
}
writeFile(cacheTpl, '.tpl.json', JSON.stringify(file, null, "\t"))
loggerSuccess('Add Template Successful!')
} catch (error) {
loggerError(error)
}
}

export {
addTpl,
}

这里我们需要对是否保存还是更新模板做一个简单的流程判断:



  1. 判断当前是否存在 tpl 的缓存文件,如果已存在缓存文件,那么需要跟当前的模板信息合并,如果不存在的话则需要创建文件,将获取的信息保存进去。

  2. 如果当前已存在缓存文件,需要根据 name 判断是已经被缓存了,如果被缓存了的话,则根据 name 来更新对应的模板信息。


接下来,我们来演示一下,使用的效果。


根据之前的操作,构建完 CLI 之后,运行 fe-cil add tpl 可以得到如下的结果:


image.png


那么在对应的路径可以看到已经将这条模板信息缓存下来了。


image.png


如上,我们已经完成一个简单的本地对模板信息添加与修改功能,同样删除也是类似的操作,根据自己的实际需求开发即可。


下载模板


在保存了模板之后,我们需要选择对应的模板下载了。


下载可以使用 download-git-repo 作为 CLI 下载的插件,这是一款非常好用的插件,支持无 clone 去下载对应的模板,非常适合我们的项目。



download-git-repo 是一款下载 git repository 的工具库,它提供了简写与 direct:url 直接下载两种方式,同时也提供直接下载代码与 git clone 的功能,非常使用与方便。



同样在下载模板的时候,我们需要给用户展示当前的保存好的模板列表,这里同样需要使用到 inquirer 工具。



  1. 使用 inquirer 创建 list 选择交互模式,读取本地模板列表,让用户选择需要的模板


export const selectTpl = () => {
const tplList = getTplList()
const promptList = [
{
type: 'list',
message: '请选择模板下载:',
name: 'name',
choices: tplList && tplList.map((tpl: ITpl) => tpl.name)
},
{
type: 'input',
message: '下载路径:',
name: 'path',
default({ name }: { name: string }) {
return name.substring(name.lastIndexOf('/') + 1)
}
}
];

inquirer.prompt(promptList).then((answers: any) => {
const { name, path } = answers
const select = tplList && tplList.filter((tpl: ITpl) => tpl.name)
const tplUrl = select && select[0].tplUrl || ''
loadTpl(name, tplUrl, path)
})
}


  1. 使用 download-git-repo 下载对应的模板


export const loadTpl = (name: string, tplUrl: string, path: string) => {
download(`direct:${tplUrl}`, getCwdPath(`./${path}`), (err: string) => {
if (err) {
loggerError(err)
} else {
loggerSuccess(`Download ${name} Template Successful!`)
}
})
}

但是问题来了,如果选择 direct 的模式,那么下载的是一个 zip 的地址,而不是正常的 git 地址,那么我们上述的地址就无效了,所以在正式下载代码之前需要对地址做一层转换。


首先看拉取规则,正常的 git 地址是 https://github.com/boty-design/react-tpl,而实际在 github 中下载的地址则是 https://codeload.github.com/boty-design/react-tpl/zip/refs/heads/main,可以看到对比正常的 github 链接的话,域名跟链接都有所改变,但是一定有项目名跟团队名,所以我们在存储的时候可以将 boty-design/react-tpl 拆出来,后期方便我们组装。


const { pathname } = new URL(tplUrl)
if (tplUrl.includes('github.com')) {
reTpl.org = pathname.substring(1)
reTpl.downLoadUrl = 'https://codeload.github.com'
}

如上述代码,解析 tplUrl 拿到的 pathname 就是我们需要的信息,再 dowload 模板的时候,重新组装下载链接即可。


image.png


image.png


如上图所示,我们可以将公共的模板下载到本地,方便同学正常开发了,但是此时还有一个问题,那就是上面的分支是 main 分支,不是每一个模板都有这个分支,可控性太差,那么我们怎么拿到项目所有的分支来选择性下载呢。


Github Api


在 Github 中对于开源、不是私有的项目,可以省去授权 token 的步骤,直接使用 Github Api 获取到对应的信息。


所以针对上面提到问题,我们可以借助 Github Api 提供的能力来解决。


获取分支的链接是 https://api.github.com/repos/boty-design/react-tpl/branches,在开发之前我们可以使用 PostMan 来测试一下是否正常返回我们需要的结果。


image.png


如上可以看到,已经能通过 Github Api 拿到我们想要的分支信息了。



如果出现了下述错误的话,没关系,只是 github 限制访问的频率罢了



image.png


针对上述的问题,我们需要的是控制频率、使用带条件的请求或者使用 token 请求 Github Api 的方式来规避,但是鉴于模板来说,一般请求频率也不会很高,只是我在开发的时候需要不断的请求来测试,才会出现这种问题,各位同学有兴趣的话可以自己试试其他的解决方案。


分支代码优化


未命名文件 (1).png


在预研完 Github Api 之后,接下来就需要对拿到的信息做一层封装,例如只有一条分支的时候用户可以直接下载模板,如果请求到多条分支的时候,则需要显示分支让用户自由选择对应的分支下载模板,整体的业务流程图如上所示。


主要逻辑代码如下:


export const selectTpl = async () => {
const prompts: any = new Subject();
let select: ITpl
let githubName: string
let path: string
let loadUrl: string

try {
const onEachAnswer = async (result: any) => {
const { name, answer } = result
if (name === 'name') {
githubName = answer
select = tplList.filter((tpl: ITpl) => tpl.name === answer)[0]
const { downloadUrl, org } = select
const branches = await getGithubBranch(select) as IBranch[]
loadUrl = `${downloadUrl}/${org}/zip/refs/heads`
if (branches.length === 1) {
loadUrl = `${loadUrl}/${branches[0].name}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
} else {
prompts.next({
type: 'list',
message: '请选择分支:',
name: 'branch',
choices: branches.map((branch: IBranch) => branch.name)
});
}
}
if (name === 'branch') {
loadUrl = `${loadUrl}/${answer}`
prompts.next({
type: 'input',
message: '下载路径:',
name: 'path',
default: githubName
});
}
if (name === 'path') {
path = answer
prompts.complete();
}
}

const onError = (error: string) => {
loggerError(error)
}

const onCompleted = () => {
loadTpl(githubName, loadUrl, path)
}

inquirer.prompt(prompts).ui.process.subscribe(onEachAnswer, onError, onCompleted);

const tplList = getTplList() as ITpl[]

prompts.next({
type: 'list',
message: '请选择模板:',
name: 'name',
choices: tplList.map((tpl: ITpl) => tpl.name)
});
} catch (error) {
loggerError(error)
}
}

上述代码,我们可以看到使用了 RXJS 来动态的渲染交互问题,因为存在一些模板的项目分支只有一个的情况。如果我们每次都需要用户都去选择分支是有多余累赘的,所以固定的问题式交互已经不适用了,我们需要借助 RXJS 动态添加 inquirer 问题,通过获取的分支数量来判断是否出现选择分支这个选项,提高用户体验。



链接:https://juejin.cn/post/6999397309180182564

收起阅读 »

CSS为什么这么难学?方法很重要!

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学? 看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属...
继续阅读 »

大家好,我是零一。前段时间我在知乎刷到这样一个提问:为什么CSS这么难学?


知乎某用户提问


看到这个问题以后,我仔细一想,CSS学习起来好像是挺困难的,它似乎没有像JavaScript那样非常系统的学习大纲,大家平时也不会用到所有的CSS,基本上用来用去就是那么几个常用的属性,甚至就连很多培训机构的入门教学视频都也只会教你一些常用的CSS(不然你以为一个几小时的教学视频怎么能让你快速入门CSS的呢?)


一般别人回答你CSS很好学也是因为它只用那些常用的属性,他很有可能并没有深入去了解。要夸张一点说,CSS应该也能算作一门小小的语言了吧,深入研究进去,知识点也不少。我们如果不是专门研究CSS的,也没必要做到了解CSS的所有属性的使用以及所有后续新特性的语法,可以根据工作场景按需学习,但要保证你学习的属性足够深入~


那么我们到底该如何学习CSS呢? 为此我列了一个简单的大纲,想围绕这几点大概讲一讲


CSS学习大纲


一、书籍、社区文章


这应该是大家学习CSS最常见的方式了(我亦如此)。有以下几个场景:


场景一:开发中遇到「文本字数超出后以省略号(...)展示」的需求,打开百度搜索:css字数过多用省略号展示,诶~搜到了!ctrl+c、ctrl+v,学废了,完工!


搜索引擎学习法


场景二:某天早晨逛技术社区,看到一篇关于CSS的文章,看到标题中有个CSS属性叫resizeresize属性是啥,我咋没用过?点进去阅读得津津有味~ two minutes later ~ 奥,原来还有这个属性,是这么用的呀,涨姿势了!


社区博客学习法


场景三:我决定了,我要好好学CSS,打开购物网站搜索:CSS书籍,迅速下单!等书到了,开始每天翻阅学习。当然了此时又有好几种情况了,分别是:



  • 就只有刚拿到书的第一天翻阅了一下,往后一直落灰

  • 看了一部分,但又懒得动手敲代码,最终感到无趣放弃了阅读

  • 认认真真看完了书,也跟着书上的代码敲了,做了很多笔记,最终学到了很多



无论是上面哪几种方式,我觉得都是挺不错的,顺便再给大家推荐几个不错的学习资源



毕竟站在巨人的肩膀上,才是最高效的,你们可以花1个小时学习到大佬们花1天才总结出来的知识


二、记住CSS的数据类型


CSS比较难学的另一个点,可能多半是因为CSS的属性太多了,而且每个属性的值又支持很多种写法,所以想要轻易记住每个属性的所有写法几乎是不太可能的。最近在逛博客时发现原来CSS也有自己的数据类型,这里引用一下张鑫旭大佬的CSS值类型文档大全,方便大家后续查阅


简单介绍一下CSS的数据类型就是这样的:


CSS数据类型


图中用<>括起来的表示一种CSS数据类型,介绍一下图中几个类型:



  • :表示值可以是数字

  • :表示元素的尺寸长度,例如3px33em34rem

  • :表示基于父元素的百分比,例如33%

  • :表示值既可以是 ,也可以是

  • :表示元素的位置。值可以是 left/right/top/bottom


来看两个CSS属性:



  • 第一个是width,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:width: 1pxwidth: 3remwidth: 33emwidth: 33%

  • 第二个属性是background-position,文档会告诉你该属性支持的数据类型有 ,那么我们就知道该属性有以下几种写法:background-position: leftbackground-position: right background-position: topbackground-position: bottombackground-position: 30%background-position: 3rem


从这个例子中我们可以看出,想要尽可能得记住更多的CSS属性的使用,可以从记住CSS数据类型(现在差不多有40+种数据类型)开始,这样你每次学习新的CSS属性时,思路就会有所转变,如下图


没记住CSS数据类型的我:


之前的思想


记住CSS数据类型的我:


现在的思想


不知道你有没有发现,如果文档只告诉你background-position支持 数据类型,你确定你能知道该属性的全部用法吗?你确实知道该属性支持background-position: 3rem这样的写法,因为你知道 数据类型包含了 数据类型,但你知道它还支持background-position: bottom 50px right 100px;这样的写法吗?为什么可以写四个值并且用空格隔开?这是谁告诉你的?


这就需要我们了解CSS的语法了,请认真看下一节


三、读懂CSS的语法


我之前某个样式中需要用到裁剪的效果,所以准备了解一下CSS中的clip-path属性怎么使用,于是就查询了比较权威的clip-path MDN,看着看着,我就发现了这个


clip-path 语法


我这才意识到我竟然连CSS的语法都看不懂。说实话,以前无论是初学CSS还是临时找一下某个CSS属性的用法,都是直接百度,瞬间就能找到自己想要的答案(例如菜鸟教程),而这次,我是真的傻了! 因为本身clip-path这个属性就比较复杂,支持的语法也比较多,光看MDN给你的示例代码根本无法Get到这个属性所有的用法和含义(菜鸟教程就更没法全面地教你了)


于是我就顺着网线去了解了一下CSS的语法中的一些符号的含义,帮助我更好得理解语法


因为关于CSS语法符号相关的知识在CSS属性值定义语法 MDN上都有一篇超级详细的介绍了(建议大家一定要先看看MDN这篇文章!!非常通俗易懂),所以我就不多做解释了,这里只放几个汇总表格


属性组合符号


解读CSS语法


以本节clip-path的语法为例,我们来简单对其中某一个属性来进行解读(只会解读部分哦,因为解读全部的话篇幅会很长很长)


先看看整体的结构


clip-path的语法


一共分为四部分,顺序是从上到下的,每两个部分之间都以where来连接,表示的是where下面的部分是对上面那个部分的补充解释


:表示的是clip-path这个属性支持的写法为:要不只写 数据类型的值,要不就最起码从 这两者之间选一种类型的值来写,要不就为none


:我们得知①中的 数据类型支持的写法为:inset()circle()ellipse()polygon()path()这5个函数


:因为我们想了解circle()这个函数的具体使用,所以就先只看这个了。我们得知circle()函数的参数支持 两种数据结构,且两者都是可写可不写,但如果要写 ,那前面必须加一个at


:首先看到 支持的属性是 (这个顾名思义就是)、closest-sidefarthest-side。而 数据类型的语法看起来就比较复杂了,我们单独来分析,因为真的非常非常长,我将 格式化并美化好给你展现出来,便于你们阅读(我也建议你们如果在学习某个属性的语法时遇到这么长的语法介绍,也像我一下把它格式化一下,这样方便你们阅读和理解)


<position>数据类型的语法


如图可得,整体分为三大部分,且这三部分是互斥关系,即这三部分只能出现一个,再根据我们前面学习的CSS语法的符号,就可以知道怎么使用了,因为这里支持的写法太多了,我直接列个表格吧(其实就是排列组合)!如果还有不懂的,你们可以仔细阅读一下MDN的语法介绍或者也可以评论区留言问我,我看到会第一时间回复!


类型支持的写法


嚯!累死我了,这支持的写法也太多太多了吧!


四、多动手尝试


上一节,我们在学习clip-path属性的语法以后,知道了我们想要的圆圈裁剪(circle())的语法怎么写,那么你就真的会了吗?可能你看了MDN给你举的例子,知道了circle(40%)大致实现的效果是咋样的,如下图


MDN clip-path的简单案例


如我前文说的一样,MDN只给你列举了circle()这个函数最简单的写法,但我们刚刚学习了其语法,得知还有别的写法(例如circle(40% at left)),而且MDN文档也只是告诉你支持哪些语法,它也并没有明确告诉你,哪个语法的作用是怎么样的,能实现什么样的效果。


此时就需要我们自己上手尝试了






<span class="scss">尝试<span class="hljs-attribute">clip-path</span>的circle()的使用</span>







看一下效果,嗯,跟MDN展示的是一样的


clip-path: circle(40%)


再修改一下值clip-path: circle(60%),看看效果


clip-path: circle(60%)


我似乎摸出了规律,看样子是以元素的中心为基准点,60%的意思就是从中心到边缘长度的60%为半径画一个圆,裁剪掉该圆之外的内容。这些都是MDN文档里没有讲到的,靠我亲手实践验证出来的。


接下来我们来试试其它的语法~


试试将值改成clip-path: circle(40% at top)


clip-path: circle(40% at top)


诶?很神奇!为什么会变成这个样子,我似乎还没找到什么规律,再把值改一下试试clip-path: circle(80% at top)


clip-path: circle(80% at top)


看样子圆心挪到了元素最上方的中间,然后以圆心到最下面边缘长度的80%为半径画了个圆进行了裁剪。至此我们似乎明白了circle()语法中at 后面的数据类型是干什么的了,大概就是用来控制裁剪时画的圆的圆心位置


剩下的时间就交给你自己来一个一个试验所有的语法了,再举个简单的例子,比如你再试一下clip-path: circle(40% at 30px),你一定好奇这是啥意思,来看看效果


clip-path: circle(40% at 30px)


直观上看,整个圆向左移动了一些距离,在我们没设置at 30px时,圆心是在元素的中心的,而现在似乎向右偏移了,大胆猜测at 30px的意思是圆心的横坐标距离元素的最左侧30px


接下来验证一下我们的猜测,继续修改其值clip-path: circle(40% at 0)


clip-path: circle(40% at 0)


很明显此时的圆心是在最左侧的中间部分,应该可以说是证明了我们刚才的猜测了,那么不妨再来验证一下纵坐标的?继续修改值clip-path: circle(40% at 0 0)


clip-path: circle(40% at 0 0)


不错,非常顺利,at 0 0中第二个0的意思就是圆心纵坐标离最上方的距离为0的意思。那么我们此时就可以放心得得出一个结论了,对于像30px33em这样的 数据类型的值,其对应的坐标是如图所示的


坐标情况


好了,本文篇幅也已经很长了,我就不继续介绍其它语法的使用了,刚才纯粹是用来举个例子,因为本文我们本来就不是在介绍circle()的使用教程,感兴趣的读者可以下去自己动手实践哦~


所以实践真的很重要很重要!! MDN文档没有给你列举每种语法对应的效果,因为每种都列出来,文档看着就很杂乱了,所以这只能靠你自己。记得张鑫旭大佬在一次直播中讲到,他所掌握的CSS的特性,也都是用大量的时间去动手试出来的,也不是看看啥文档就能理解的,所以你在大佬们的一篇文章中了解到的某个CSS属性的使用,可能是他们花费几小时甚至十几个小时研究出来的。


CSS很多特性会有兼容性问题,因为市面上有很多家浏览器厂商,它们支持的程度各不相同,而我们平常了解CSS某个属性的兼容性,是这样的


查看MDN的某个属性的浏览器兼容性


clip-path的浏览器兼容性


通过Can I Use来查找某个属性的浏览器兼容性


can i use


这些都是正确的,但有时候可能某些CSS属性的浏览器兼容性都无法通过这两个渠道获取到,那么该怎么办呢?手动试试每个浏览器上该属性的效果是否支持呗(鑫旭大佬说他以前也会这么干),这点我就不举例子了,大家应该能体会到


☀️ 最后


其实每个CSS大佬都不是因为某些快捷的学习路径而成功的,他们都是靠着不断地动手尝试、记录、总结各种CSS的知识,也会经常用学到的CSS知识去做一个小demo用于巩固,前几个月加了大漠老师的好友,我就经常看到他朋友圈有一些CSS新特性的demo演示代码和文章(真心佩服),coco大佬也是,也经常会发一些单纯用CSS实现的炫酷特效(据说没有他实现不了的特效哦~)


另外,如果想要更加深入,你们还可以关注一下CSS的规范,这个比较权威的就是W3C的CSS Working Group了,里面有很多CSS的规范文档


w3c css规范


好了,再推荐几本业界公认的还算不错的书籍吧~例如《CSS权威指南》、《CSS揭秘》、《CSS世界》、《CSS新世界》等等...


最后对于「如何学习CSS?」这个话题,你还有什么问题或者你觉得还不错的学习方法吗?欢迎在评论区留言讨论~



链接:https://juejin.cn/post/6999418363239727111

收起阅读 »

WMS在Activity启动中的职责 添加窗体(三)

Context 获取系统服务在正式聊WMS之前,我们先来看看context.getSystemService其核心原理,才能找到WindowManager的实现类: @Override public Object getSystemService...
继续阅读 »

Context 获取系统服务

在正式聊WMS之前,我们先来看看context.getSystemService其核心原理,才能找到WindowManager的实现类:

    @Override
public Object getSystemService(String name) {
return SystemServiceRegistry.getSystemService(this, name);
}
private static final HashMap<String, ServiceFetcher<?>> SYSTEM_SERVICE_FETCHERS =
new HashMap<String, ServiceFetcher<?>>();

public static Object getSystemService(ContextImpl ctx, String name) {
ServiceFetcher<?> fetcher = SYSTEM_SERVICE_FETCHERS.get(name);
return fetcher != null ? fetcher.getService(ctx) : null;
}

能看到是实际上所有的我们通过Context获取系统服务,是通过SYSTEM_SERVICE_FETCHERS这个提前存放在HashMap的服务集合中。这个服务是在静态代码域中提前注册。

        registerService(Context.WINDOW_SERVICE, WindowManager.class,
new CachedServiceFetcher<WindowManager>() {
@Override
public WindowManager createService(ContextImpl ctx) {
return new WindowManagerImpl(ctx);
}});

能看到此时实际上WindowManager的interface是由WindowManagerImpl实现的。

这里先上一个WindowManager的UML类图。

image.png

我们能够从这个UML图能够看到,其实所有的事情都委托给WindowManagerGlobal工作。因此我们只需要看WindowManagerGlobal中做了什么。

因此我们要寻求WindowManager的addView的方法,实际上就是看WindowManagerGlobal的addView方法。

public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
...
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
parentWindow.adjustLayoutParamsForSubWindow(wparams);
} else {
// If there's no parent, then hardware acceleration for this view is
// set from the application's hardware acceleration setting.
final Context context = view.getContext();
if (context != null
&& (context.getApplicationInfo().flags
& ApplicationInfo.FLAG_HARDWARE_ACCELERATED) != 0) {
wparams.flags |= WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED;
}
}

ViewRootImpl root;
View panelParentView = null;

synchronized (mLock) {
// Start watching for system property changes.
...
int index = findViewLocked(view, false);
if (index >= 0) {
if (mDyingViews.contains(view)) {
// Don't wait for MSG_DIE to make it's way through root's queue.
mRoots.get(index).doDie();
} else {
throw new IllegalStateException("View " + view
+ " has already been added to the window manager.");
}
// The previous removeView() had not completed executing. Now it has.
}

// If this is a panel window, then find the window it is being
// attached to for future reference.
if (wparams.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
wparams.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
final int count = mViews.size();
for (int i = 0; i < count; i++) {
if (mRoots.get(i).mWindow.asBinder() == wparams.token) {
panelParentView = mViews.get(i);
}
}
}

root = new ViewRootImpl(view.getContext(), display);

view.setLayoutParams(wparams);

mViews.add(view);
mRoots.add(root);
mParams.add(wparams);

// do this last because it fires off messages to start doing things
try {
root.setView(view, wparams, panelParentView);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
if (index >= 0) {
removeViewLocked(index, true);
}
throw e;
}
}
}

这里能够看到一个新的addView的时候,会找到是否有父Window。没有则继续往后走,判断新建窗体的type是否是子窗口类型,是则查找传进来的Binder对象和存储在缓存中的Binder对象又没有对应的Window。有则作为本次新建窗口的复窗口。

最后能够看到我们熟悉的类ViewRootImpl。这个类可以说是所有View绘制的根部核心,这个类会在后面View绘制流程聊聊。最后会调用ViewRootImpl的setView进一步的沟通系统应用端。

这里涉及到了几个有趣的宏,如WindowManager.LayoutParams.FIRST_SUB_WINDOW 。它们象征这当前Window处于什么层级。

Window的层级

Window的层级,我们大致可以分为3大类:System Window(系统窗口),Application Window(应用窗口),Sub Window(子窗口)

Application Window(应用窗口)

Application值得注意的有这么几个宏:

type描述
FIRST_APPLICATION_WINDOW = 1应用程序窗口初始值
TYPE_BASE_APPLICATION = 1应用窗口类型初始值,其他窗口以此为基准
TYPE_APPLICATION = 2普通应用程序窗口类型
TYPE_APPLICATION_STARTING = 3应用程序的启动窗口类型,不是应用进程支配,当第一个应用进程诞生了启动窗口就会销毁
TYPE_DRAWN_APPLICATION = 4应用显示前WindowManager会等待这种窗口类型绘制完毕,一般在多用户使用
LAST_APPLICATION_WINDOW = 99应用窗口类型最大值

因此此时我们能够清楚,应用窗口的范围在1~99之间。

Sub Window(子窗口)

type描述
FIRST_SUB_WINDOW = 1000子窗口初始值
TYPE_APPLICATION_PANEL = FIRST_SUB_WINDOW应用的panel窗口,在父窗口上显示
TYPE_APPLICATION_MEDIA = FIRST_SUB_WINDOW + 1多媒体内容子窗口,在父窗口之下
TYPE_APPLICATION_SUB_PANEL = FIRST_SUB_WINDOW + 2也是一种panel子窗口,位于所有TYPE_APPLICATION_PANEL之上
TYPE_APPLICATION_ATTACHED_DIALOG = FIRST_SUB_WINDOW + 3dialog弹窗
TYPE_APPLICATION_MEDIA_OVERLAY = FIRST_SUB_WINDOW + 4多媒体内容窗口的覆盖层
TYPE_APPLICATION_ABOVE_SUB_PANEL = FIRST_SUB_WINDOW + 5位于子panel之上窗口
LAST_SUB_WINDOW = 1999子窗口类型最大值

能够看到子窗口的范围从1000~1999

System Window(系统窗口)

type描述
FIRST_SYSTEM_WINDOW = 2000系统窗口初始值
TYPE_STATUS_BAR = FIRST_SYSTEM_WINDOW系统状态栏
TYPE_SEARCH_BAR = FIRST_SYSTEM_WINDOW+1搜索条窗口
TYPE_PHONE = FIRST_SYSTEM_WINDOW+2通话窗口
TYPE_SYSTEM_ALERT = FIRST_SYSTEM_WINDOW+3alert窗口,电量不足时警告
TYPE_KEYGUARD = FIRST_SYSTEM_WINDOW+4屏保窗口
TYPE_TOAST = FIRST_SYSTEM_WINDOW+5Toast提示窗口
TYPE_SYSTEM_OVERLAY = FIRST_SYSTEM_WINDOW+6系统覆盖层窗口,这个层不会响应点击事件
TYPE_PRIORITY_PHONE = FIRST_SYSTEM_WINDOW+7电话优先层,在屏保状态下显示通话
TYPE_SYSTEM_DIALOG = FIRST_SYSTEM_WINDOW+8系统层级的dialog,比如RecentAppDialog
TYPE_KEYGUARD_DIALOG= FIRST_SYSTEM_WINDOW+9屏保时候对话框(如qq屏保时候的聊天框)
TYPE_SYSTEM_ERROR= FIRST_SYSTEM_WINDOW+10系统错误窗口
TYPE_INPUT_METHOD= FIRST_SYSTEM_WINDOW+11输入法窗口
TYPE_INPUT_METHOD_DIALOG= FIRST_SYSTEM_WINDOW+12输入法窗口上的对话框
TYPE_WALLPAPER= FIRST_SYSTEM_WINDOW+13壁纸窗口
TYPE_STATUS_BAR_PANEL = FIRST_SYSTEM_WINDOW+14滑动状态栏窗口
LAST_SYSTEM_WINDOW = 2999系统窗口最大值

常见的系统级别窗口主要是这几个。能够注意到系统窗口层级是从2000~2999。

这些层级有什么用的?这些层级会作为参考,将会插入到显示栈的位置,层级值越高,越靠近用户。这个逻辑之后会聊到。

ViewRootImpl setView

ViewRootImpl里面包含了许多事情,主要是包含了我们熟悉的View的绘制流程,以及添加Window实例的流程。

本文是关于WMS,因此我们只需要看下面这个核心函数

这个方法有两个核心requestLayout以及addToDisplay。

  • 1.requestLayout实际上就是指View的绘制流程,并且最终会把像素数据发送到Surface底层。
  • 2.mWindowSession.addToDisplay 添加Window实例到WMS中。

WindowManager的Session设计思想

先来看看Session类:

class Session extends IWindowSession.Stub implements IBinder.DeathRecipient

得知此时Session实现了一个IWindowSession的Binder对象。并且实现了Binder的死亡监听。

那么这个Session是从哪里来的呢?实际上是通过WMS通过跨进程通信把数据这个Binder对象传递过来的:

    @Override
public IWindowSession openSession(IWindowSessionCallback callback, IInputMethodClient client,
IInputContext inputContext) {
if (client == null) throw new IllegalArgumentException("null client");
if (inputContext == null) throw new IllegalArgumentException("null inputContext");
Session session = new Session(this, callback, client, inputContext);
return session;
}

通着这种方式,就能把一个Session带上WMS相关的环境送给客户端操作。这种方式和什么很相似,实际上和servicemanager查询服务Binder的思路几乎一模一样。

@Override
public int addToDisplay(IWindow window, int seq, WindowManager.LayoutParams attrs,
int viewVisibility, int displayId, Rect outFrame, Rect outContentInsets,
Rect outStableInsets, Rect outOutsets,
DisplayCutout.ParcelableWrapper outDisplayCutout, InputChannel outInputChannel) {
return mService.addWindow(this, window, seq, attrs, viewVisibility, displayId, outFrame,
outContentInsets, outStableInsets, outOutsets, outDisplayCutout, outInputChannel);
}

很有趣的是,我们能够看到,按照道理我们需要添加窗体实例到WMS中。从逻辑上来讲,我们只需要做一次跨进程通信即可。但是为什么需要一个Session作为中转站呢?

image.png

能够看到实际上Session(会话)做的事情不仅仅只有沟通WMS这么简单。实际上它还同时处理了窗口上的拖拽,输入法等逻辑,更加重要的是Session面对着系统多个服务,但是通过这个封装,应用程序只需要面对这个Sesion接口,真的是名副其实的"会话"。

这种设计想什么?实际上就是我们常说的门面设计模式。

IWindow对象

注意,这里面除了IWindowSession之外,当我们调用addWindow添加Window到WMS中的时候,其实还存在一个IWindow接口.这个IWindow是指PhoneWindow吗?

很遗憾。并不是。PhoneWindow基础的接口只有Window接口。它并不是一个IBinder对象。我们转过头看看ViewRootImpl.

public ViewRootImpl(Context context, Display display) {
mContext = context;
mWindowSession = WindowManagerGlobal.getWindowSession();
mDisplay = display;
mBasePackageName = context.getBasePackageName();
mThread = Thread.currentThread();
mLocation = new WindowLeaked(null);
mLocation.fillInStackTrace();
mWidth = -1;
mHeight = -1;
mDirty = new Rect();
mTempRect = new Rect();
mVisRect = new Rect();
mWinFrame = new Rect();
mWindow = new W(this);
mTargetSdkVersion = context.getApplicationInfo().targetSdkVersion;
mViewVisibility = View.GONE;
mTransparentRegion = new Region();
mPreviousTransparentRegion = new Region();
mFirst = true; // true for the first time the view is added
mAdded = false;
mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,
context);
...
mViewConfiguration = ViewConfiguration.get(context);
mDensity = context.getResources().getDisplayMetrics().densityDpi;
mNoncompatDensity = context.getResources().getDisplayMetrics().noncompatDensityDpi;
mFallbackEventHandler = new PhoneFallbackEventHandler(context);
mChoreographer = Choreographer.getInstance();
mDisplayManager = (DisplayManager)context.getSystemService(Context.DISPLAY_SERVICE);

if (!sCompatibilityDone) {
sAlwaysAssignFocus = mTargetSdkVersion < Build.VERSION_CODES.P;

sCompatibilityDone = true;
}

loadSystemProperties();
}

能看到此时,实际上在ViewRootImpl的构造函数会对应当前生成一个W的内部类。这个内部类:

static class W extends IWindow.Stub

这个内部类实际上就是一个Binder类,里面回调了很多方法来操作当前的ViewRootImpl。换句话说,就是把当前的ViewRootImpl的代理W交给WMS去管理。

那么我们可以总结,IWindow是WMS用来间接操作ViewRootImpl中的View,IWindowSession是App用来间接操作WMS。

WMS.addWindow

WMS的addWindow很长,因此我这边拆开成3部分聊

添加窗体的准备步骤

我们抛开大部分的校验逻辑。实际上可以把这个过程总结为以下几点:

  • 1.判断又没有相关的权限
  • 2.尝试着获取当前displayId对应的DisplayContent,没有则创建。其逻辑实际上和我上一篇说的创建DisplayContent一摸一样
  • 3.通过mWindowMap,判断当前IWindow是否被添加过,是的话说明已经存在这个Window,不需要继续添加
  • 4.如果当前窗口类型是子窗口,则会通过WindowToken.attrs参数中的token去查找当前窗口的父窗口是什么。
  • 5.如果有父窗口,则从DisplayContent中以父窗口的IWindow获取父窗口WindowToken的对象,否则尝试的获取当前窗口对应的WindowToken对象。

我们稍微探索一下其中的几个核心:

通过windowForClientLocked查找父窗口的WindowState

final WindowState windowForClientLocked(Session session, IBinder client, boolean throwOnError) {
WindowState win = mWindowMap.get(client);
if (localLOGV) Slog.v(TAG_WM, "Looking up client " + client + ": " + win);
if (win == null) {
if (throwOnError) {
throw new IllegalArgumentException(
"Requested window " + client + " does not exist");
}
Slog.w(TAG_WM, "Failed looking up window callers=" + Debug.getCallers(3));
return null;
}
if (session != null && win.mSession != session) {
if (throwOnError) {
throw new IllegalArgumentException("Requested window " + client + " is in session "
+ win.mSession + ", not " + session);
}
Slog.w(TAG_WM, "Failed looking up window callers=" + Debug.getCallers(3));
return null;
}

return win;
}

实际上可以看到这里面是从mWindowMap通过IWindow获取WindowState对象。还记得我上篇说过很重要的数据结构吗?mWindowMap实际上是保存着WMS中IWindow对应WindowState对象。IWindow本质上是WMS控制ViewRootImpl的Binder接口。因此我们可以把WindowState看成应用进程的对应的对象也未尝不可。

获取对应的WindowToken

            AppWindowToken atoken = null;
final boolean hasParent = parentWindow != null;
//从DisplayContent找到对应的WIndowToken
WindowToken token = displayContent.getWindowToken(
hasParent ? parentWindow.mAttrs.token : attrs.token);

从这里面我们能够看到WindowToken,是通过DisplayContent获取到的。

WindowToken getWindowToken(IBinder binder) {
return mTokenMap.get(binder);
}

这样就能看到我前两篇提到过的很重要的数据结构:mTokenMap以及mWindowMap。这两者要稍微区分一下:
mWindowMap是以IWindow为key,WindowState为value。
mTokenMap是以WindowState的IBinder(一般为IApplicationToken)为key,WindowToken为value

还记得mTokenMap在Activity的启动流程中做的事情吗?在创建AppWIndowContainer的时候,会同时创建AppWindowToken,AppWIndowToken的构造会把当前的IBinder作为key,AppWindowToken作为value添加到mTokenMap中。

也就是说,如果系统想要通过应用进程给的IWindow找到真正位于WMS中Window的句柄,必须通过这两层变换才能真正找到。

拆分情况获取对应的WindowToken和AppWindowToken

这个时候就分为两种情况,一种是存在WindowToken,一种是不存在WindowToken。

            boolean addToastWindowRequiresToken = false;

if (token == null) {
//校验窗口参数是否合法
...

final IBinder binder = attrs.token != null ? attrs.token : client.asBinder();
final boolean isRoundedCornerOverlay =
(attrs.privateFlags & PRIVATE_FLAG_IS_ROUNDED_CORNERS_OVERLAY) != 0;
token = new WindowToken(this, binder, type, false, displayContent,
session.mCanAddInternalSystemWindow, isRoundedCornerOverlay);
} else if (rootType >= FIRST_APPLICATION_WINDOW && rootType <= LAST_APPLICATION_WINDOW) {
atoken = token.asAppWindowToken();
if (atoken == null) {
return WindowManagerGlobal.ADD_NOT_APP_TOKEN;
}
...
} else if (atoken.removed) {
...
} else if (type == TYPE_APPLICATION_STARTING && atoken.startingWindow != null) {
...

}
} else if (rootType == TYPE_INPUT_METHOD) {
...

} else if (rootType == TYPE_VOICE_INTERACTION) {
...
} else if (rootType == TYPE_WALLPAPER) {
...
} else if (rootType == TYPE_DREAM) {
...
} else if (rootType == TYPE_ACCESSIBILITY_OVERLAY) {
...
} else if (type == TYPE_TOAST) {
....
} else if (type == TYPE_QS_DIALOG) {
...
} else if (token.asAppWindowToken() != null) {

attrs.token = null;
token = new WindowToken(this, client.asBinder(), type, false, displayContent,
session.mCanAddInternalSystemWindow);
}

当我们通过mTokenMap获取WindowToken的时候,大致分为四种情况。WindowToken会尝试的获取父窗口对应的Token,找不到则使用WindowManager.LayoutParams中的WindowToken。一般来说我们找到的都有父亲的WindowToken。

  • 1.无关应用的找不到WindowToken
  • 2.有关应用找不到WindowToken。
  • 3.无关应用找到WindowToken
  • 4.有关应用找到WindowToken

前两种情况解析

实际上前两种情况,一旦发现找不到WindowToken,如果当前的窗口和应用相关的,就一定爆错误。如Toast,输入法,应用窗口等等。

因此在Android 8.0开始,当我们想要显示Toast的时候,加入传入的Context是Application而不是Activity,此时一旦发现mTokenMap中找不到IApplicationToken对应的WindowToken就爆出了错误。正确的做法应该是需要获取Activity当前的Context。

在上面的情况应用启动窗口,此时并没有启动Activity。因此不可能会被校验拦下,因此并没有异常抛出。就会自己创建一个WindowToken。

后两种的解析

当找到WindowToken,一般是指Activity启动之后,在AppWindowToken初始化后,自动加入了mTokenMap中。此时的情况稍微复杂了点。

当是子窗口的时候,则会判断当前的WindowToken是不是AppWindowToken。不是,或者被移除等异常情况则报错。

如果是壁纸,输入法,系统弹窗,toast等窗口模式,子窗口和父窗口的模式必须一致。

当此时的AppWindowToken不为空的时候,说明在New的时候已经生成,且没有移除,将会生成一个新的WindowToken。

为什么要生成一个新的windowToken?可以翻阅之前我写的文章,只要每一次调用一次构造函数将会把当前的WindowToken添加到mTokenMap中,实际上也是担心,对应的AppWindowToken出现的重新绑定的问题。

添加WindowState实例到数据结构

但是别忘了,我们这个时候还需要把相关的数据结构存储到全局。

            final WindowState win = new WindowState(this, session, client, token, parentWindow,
appOp[0], seq, attrs, viewVisibility, session.mUid,
session.mCanAddInternalSystemWindow);
if (win.mDeathRecipient == null) {
...
return WindowManagerGlobal.ADD_APP_EXITING;
}

if (win.getDisplayContent() == null) {
...
return WindowManagerGlobal.ADD_INVALID_DISPLAY;
}

final boolean hasStatusBarServicePermission =
mContext.checkCallingOrSelfPermission(permission.STATUS_BAR_SERVICE)
== PackageManager.PERMISSION_GRANTED;
mPolicy.adjustWindowParamsLw(win, win.mAttrs, hasStatusBarServicePermission);
win.setShowToOwnerOnlyLocked(mPolicy.checkShowToOwnerOnly(attrs));

res = mPolicy.prepareAddWindowLw(win, attrs);
if (res != WindowManagerGlobal.ADD_OKAY) {
return res;
}
// From now on, no exceptions or errors allowed!

res = WindowManagerGlobal.ADD_OKAY;
if (mCurrentFocus == null) {
mWinAddedSinceNullFocus.add(win);
}

if (excludeWindowTypeFromTapOutTask(type)) {
displayContent.mTapExcludedWindows.add(win);
}

origId = Binder.clearCallingIdentity();

win.attach();
//以IWindow为key,WindowState为value存放到WindowMap中
mWindowMap.put(client.asBinder(), win);

win.initAppOpsState();

....
win.mToken.addWindow(win);

因为完全可能出现新的WindowToken,因此干脆会创建一个新的WindowState。此时会对调用WindowState.attach方法

    void attach() {
mSession.windowAddedLocked(mAttrs.packageName);
}

这方法挺重要的,Session做了一次添加锁定。

void windowAddedLocked(String packageName) {
mPackageName = packageName;
mRelayoutTag = "relayoutWindow: " + mPackageName;
if (mSurfaceSession == null) {
if (WindowManagerService.localLOGV) Slog.v(
TAG_WM, "First window added to " + this + ", creating SurfaceSession");
mSurfaceSession = new SurfaceSession();
if (SHOW_TRANSACTIONS) Slog.i(
TAG_WM, " NEW SURFACE SESSION " + mSurfaceSession);
mService.mSessions.add(this);
if (mLastReportedAnimatorScale != mService.getCurrentAnimatorScale()) {
mService.dispatchNewAnimatorScaleLocked(this);
}
}
mNumWindow++;
}

此时的工作是什么?联系上下文,当我们新增了PhoneWindow,就会一个ViewRootImpl,也因此新增了Session。此时说明诞生一个新界面,此时已经诞生了相关的容器对象,但是相关的绘制到底层对象还没有创建出来。

命名逻辑和Session很相似。Session是WMS给应用App的会话对象,SurfaceSession是SurfaceFlinger面向上层每一个WIndow需要绘制内容对象。

这个SurfaceSession和SurfaceControl都是重点,联通到SurfaceFlinger很重要的对象。

最后再添加到mWindowMap中。并且把WindowState添加到WindowToken中,让每一个WindowToken赋予状态的信息。我们稍微探索一下addWindow的方法。


收起阅读 »

Android自定义view之3D正方体

前言在之前写了一篇关于3D效果的文章,借助传感器展示,有小伙伴问可不可以改成手势滑动操作(事件分发),所以出一篇文章传感器相关文章链接:Android 3D效果的实现一、小提相对于常见的自定义view而言,继承的GLSurfaceView只有两个构造函数。可以...
继续阅读 »

前言

在之前写了一篇关于3D效果的文章,借助传感器展示,有小伙伴问可不可以改成手势滑动操作(事件分发),所以出一篇文章


传感器相关文章链接:Android 3D效果的实现

一、小提

相对于常见的自定义view而言,继承的GLSurfaceView只有两个构造函数。可以理解为没有提供获取自定义属性的方法。

    public TouchSurfaceView(Context context) {
super(context);
}

public TouchSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
}

二、将传感器改成事件分发机制

    @Override
public boolean onTouchEvent(MotionEvent e) {
float x = e.getX();
float y = e.getY();
switch (e.getAction()) {
case MotionEvent.ACTION_MOVE:
float dx = x - mPreviousX;
float dy = y - mPreviousY;
mRenderer.mAngleX += dx * TOUCH_SCALE_FACTOR;
mRenderer.mAngleY += dy * TOUCH_SCALE_FACTOR;
requestRender();
}
mPreviousX = x;
mPreviousY = y;
return true;
}

要注意还有一个滚动球事件

    @Override
public boolean onTrackballEvent(MotionEvent e) {
mRenderer.mAngleX += e.getX() * TRACKBALL_SCALE_FACTOR;
mRenderer.mAngleY += e.getY() * TRACKBALL_SCALE_FACTOR;
requestRender();
return true;
}

三、在Activity中使用

  mGLSurfaceView = new TouchSurfaceView(this);
setContentView(mGLSurfaceView);
mGLSurfaceView.requestFocus();
mGLSurfaceView.setFocusableInTouchMode(true);

注意要在对应生命周期中处理

    @Override
protected void onResume() {
super.onResume();
mGLSurfaceView.onResume();
}

@Override
protected void onPause() {
super.onPause();
mGLSurfaceView.onPause();
}

四、源码

TouchSurfaceView.java

除去前面的修改部分,其他大多与链接文章相同,仅将传感器改成了事件分发。(代码中难点有注释)

public class TouchSurfaceView extends GLSurfaceView {
private final float TOUCH_SCALE_FACTOR = 180.0f / 320;
private final float TRACKBALL_SCALE_FACTOR = 36.0f;
private CubeRenderer mRenderer;
private float mPreviousX;
private float mPreviousY;

public TouchSurfaceView(Context context) {
super(context);
mRenderer = new CubeRenderer();
setRenderer(mRenderer);
setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
}

public TouchSurfaceView(Context context, AttributeSet attrs) {
super(context, attrs);
}


@Override
public boolean onTrackballEvent(MotionEvent e) {
mRenderer.mAngleX += e.getX() * TRACKBALL_SCALE_FACTOR;
mRenderer.mAngleY += e.getY() * TRACKBALL_SCALE_FACTOR;
requestRender();
return true;
}

@Override
public boolean onTouchEvent(MotionEvent e) {
float x = e.getX();
float y = e.getY();
switch (e.getAction()) {
case MotionEvent.ACTION_MOVE:
float dx = x - mPreviousX;
float dy = y - mPreviousY;
mRenderer.mAngleX += dx * TOUCH_SCALE_FACTOR;
mRenderer.mAngleY += dy * TOUCH_SCALE_FACTOR;
requestRender();
}
mPreviousX = x;
mPreviousY = y;
return true;
}


private class CubeRenderer implements GLSurfaceView.Renderer {

private Cube mCube;
public float mAngleX;
public float mAngleY;
public CubeRenderer() {
mCube =new Cube();
}

public void onDrawFrame(GL10 gl) {
// | GL10.GL_DEPTH_BUFFER_BIT
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glMatrixMode(GL10.GL_MODELVIEW);
gl.glLoadIdentity();
gl.glTranslatef(0, 0, -3.0f);
gl.glRotatef(mAngleX, 0, 1, 0);
gl.glRotatef(mAngleY, 1, 0, 0);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
mCube.draw(gl);
}


@Override
public void onSurfaceCreated(GL10 gl, javax.microedition.khronos.egl.EGLConfig config) {
gl.glDisable(GL10.GL_DITHER);
gl.glClearColor(1,1,1,1);
}

public void onSurfaceChanged(GL10 gl, int width, int height) {
gl.glViewport(0, 0, width, height);
//设置投影矩阵。但并不需要在每次绘制时都做,通常情况下,当视图调整大小时,需要设置一个新的投影。
float ratio = (float) width / height;
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glFrustumf(-ratio, ratio, -1, 1, 1, 10);
}

}



public class Cube {
//opengl坐标系中采用的是3维坐标:
private FloatBuffer mVertexBuffer;
private FloatBuffer mColorBuffer;
private ByteBuffer mIndexBuffer;

public Cube() {
final float vertices[] = {
-1, -1, -1, 1, -1, -1,
1, 1, -1, -1, 1, -1,
-1, -1, 1, 1, -1, 1,
1, 1, 1, -1, 1, 1,
};

final float colors[] = {
0, 1, 1, 1, 1, 1, 1, 1,
1, 1, 0, 1, 1, 1, 1, 1,
1, 1, 1, 1, 0, 1, 1, 1,
1, 1, 1, 1, 1, 1, 0, 1,
};

final byte indices[] = {
0, 4, 5, 0, 5, 1,
1, 5, 6, 1, 6, 2,
2, 6, 7, 2, 7, 3,
3, 7, 4, 3, 4, 0,
4, 7, 6, 4, 6, 5,
3, 0, 1, 3, 1, 2
};

ByteBuffer vbb = ByteBuffer.allocateDirect(vertices.length*4);
vbb.order(ByteOrder.nativeOrder());
mVertexBuffer = vbb.asFloatBuffer();
mVertexBuffer.put(vertices);
mVertexBuffer.position(0);

ByteBuffer cbb = ByteBuffer.allocateDirect(colors.length*4);
cbb.order(ByteOrder.nativeOrder());
mColorBuffer = cbb.asFloatBuffer();
mColorBuffer.put(colors);
mColorBuffer.position(0);

mIndexBuffer = ByteBuffer.allocateDirect(indices.length);
mIndexBuffer.put(indices);
mIndexBuffer.position(0);
}

public void draw(GL10 gl) {
//启用服务器端GL功能。
gl.glEnable(GL10.GL_CULL_FACE);
//定义多边形的正面和背面。
//参数:
//mode——多边形正面的方向。GL_CW和GL_CCW被允许,初始值为GL_CCW。
gl.glFrontFace(GL10.GL_CW);
//选择恒定或光滑着色模式。
//GL图元可以采用恒定或者光滑着色模式,默认值为光滑着色模式。当图元进行光栅化的时候,将引起插入顶点颜色计算,不同颜色将被均匀分布到各个像素片段。
//参数:
//mode——指明一个符号常量来代表要使用的着色技术。允许的值有GL_FLAT 和GL_SMOOTH,初始值为GL_SMOOTH。
gl.glShadeModel(GL10.GL_SMOOTH);
//定义一个顶点坐标矩阵。
//参数:
//
//size——每个顶点的坐标维数,必须是2, 3或者4,初始值是4。
//
//type——指明每个顶点坐标的数据类型,允许的符号常量有GL_BYTE, GL_SHORT, GL_FIXED和GL_FLOAT,初始值为GL_FLOAT。
//
//stride——指明连续顶点间的位偏移,如果为0,顶点被认为是紧密压入矩阵,初始值为0。
//
//pointer——指明顶点坐标的缓冲区,如果为null,则没有设置缓冲区。
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, mVertexBuffer);
//定义一个颜色矩阵。
//size指明每个颜色的元素数量,必须为4。type指明每个颜色元素的数据类型,stride指明从一个颜色到下一个允许的顶点的字节增幅,并且属性值被挤入简单矩阵或存储在单独的矩阵中(简单矩阵存储可能在一些版本中更有效率)。
gl.glColorPointer(4, GL10.GL_FLOAT, 0, mColorBuffer);
//由矩阵数据渲染图元
//可以事先指明独立的顶点、法线、颜色和纹理坐标矩阵并且可以通过调用glDrawElements方法来使用它们创建序列图元。
gl.glDrawElements(GL10.GL_TRIANGLES, 36, GL10.GL_UNSIGNED_BYTE, mIndexBuffer);
}
}
}

MainActivity.java

public class MainActivity extends AppCompatActivity {
private GLSurfaceView mGLSurfaceView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mGLSurfaceView = new TouchSurfaceView(this);
setContentView(mGLSurfaceView);
mGLSurfaceView.requestFocus();
mGLSurfaceView.setFocusableInTouchMode(true);
}

@Override
protected void onResume() {
super.onResume();
mGLSurfaceView.onResume();
}

@Override
protected void onPause() {
super.onPause();
mGLSurfaceView.onPause();
}


}

总结

收起阅读 »

内存管理(MRC、ARC)

一、 什么是内存管理程序在运行的过程中通常通过以下行为,来增加程序的的内存占用创建一个OC对象定义一个变量调用一个函数或者方法而一个移动设备的内存是有限的,每个软件所能占用的内存也是有限的当程序所占用的内存较多时,系统就会发出内存警告,这时就得回收一些不需要再...
继续阅读 »

一、 什么是内存管理

  • 程序在运行的过程中通常通过以下行为,来增加程序的的内存占用
    • 创建一个OC对象
    • 定义一个变量
    • 调用一个函数或者方法
  • 而一个移动设备的内存是有限的,每个软件所能占用的内存也是有限的
  • 当程序所占用的内存较多时,系统就会发出内存警告,这时就得回收一些不需要再使用的内存空间。比如回收一些不需要使用的对象、变量等
  • 如果程序占用内存过大,系统可能会强制关闭程序,造成程序崩溃、闪退现象,影响用户体验

所以,我们需要对内存进行合理的分配内存、清除内存,回收那些不需要再使用的对象。从而保证程序的稳定性。

那么,那些对象才需要我们进行内存管理呢?

  • 任何继承了NSObject的对象需要进行内存管理
  • 而其他非对象类型(int、char、float、double、struct、enum等) 不需要进行内存管理

这是因为

  • 继承了NSObject的对象的存储在操作系统的里边。
  • 操作系统的:一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表
  • 非OC对象一般放在操作系统的里面
  • 操作系统的:由操作系统自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈(先进后出)
  • 示例:
int main(int argc, const char * argv[])
{
@autoreleasepool {
int a = 10; // 栈
int b = 20; // 栈
// p : 栈
// Person对象(计数器==1) : 堆
Person *p = [[Person alloc] init];
}
// 经过上面代码后, 栈里面的变量a、b、p 都会被回收
// 但是堆里面的Person对象还会留在内存中,因为它是计数器依然是1
return 0;
}




二、 内存管理模型

提供给Objective-C程序员的基本内存管理模型有以下3种:

  • 自动垃圾收集(iOS运行环境不支持)
  • 手工引用计数和自动释放池(MRC)
  • 自动引用计数(ARC)

三、MRC 手动管理内存(Manual Reference Counting)

1. 引用计数器

系统是根据对象的引用计数器来判断什么时候需要回收一个对象所占用的内存

  • 引用计数器是一个整数
  • 从字面上, 可以理解为”对象被引用的次数”
  • 也可以理解为: 它表示有多少人正在用这个对象
  • 每个OC对象都有自己的引用计数器
  • 任何一个对象,刚创建的时候,初始的引用计数为1
    • 当使用alloc、new或者copy创建一个对象时,对象的引用计数器默认就是1
  • 当没有任何人使用这个对象时,系统才会回收这个对象, 也就是说
    • 当对象的引用计数器为0时,对象占用的内存就会被系统回收
    • 如果对象的计数器不为0,那么在整个程序运行过程,它占用的内存就不可能被回收(除非整个程序已经退出 )

2. 引用计数器操作

  • 为保证对象的存在,每当创建引用到对象需要给对象发送一条retain消息,可以使引用计数器值+1 ( retain 方法返回对象本身)
  • 当不再需要对象时,通过给对象发送一条release消息,可以使引用计数器值-1
  • 给对象发送retainCount消息,可以获得当前的引用计数器值
  • 当对象的引用计数为0时,系统就知道这个对象不再需要使用了,所以可以释放它的内存,通过给对象发送dealloc消息发起这个过程。
  • 需要注意的是:release并不代表销毁\回收对象,仅仅是计数器-1

int main(int argc, const char * argv[]) {
@autoreleasepool {
// 只要创建一个对象默认引用计数器的值就是1
Person *p = [[Person alloc] init];
NSLog(@"retainCount = %lu", [p retainCount]); // 1

// 只要给对象发送一个retain消息, 对象的引用计数器就会+1
[p retain];

NSLog(@"retainCount = %lu", [p retainCount]); // 2
// 通过指针变量p,给p指向的对象发送一条release消息
// 只要对象接收到release消息, 引用计数器就会-1
// 只要一个对象的引用计数器为0, 系统就会释放对象

[p release];
// 需要注意的是: release并不代表销毁\回收对象, 仅仅是计数器-1
NSLog(@"retainCount = %lu", [p retainCount]); // 1

[p release]; // 0
NSLog(@"--------");
}
// [p setAge:20]; // 此时对象已经被释放
return 0;
}

3. dealloc方法

  • 当一个对象的引用计数器值为0时,这个对象即将被销毁,其占用的内存被系统回收
  • 对象即将被销毁时系统会自动给对象发送一条dealloc消息(因此,从dealloc方法有没有被调用,就可以判断出对象是否被销毁)
  • dealloc方法的重写
    • 一般会重写dealloc方法,在这里释放相关资源,dealloc就是对象的遗言
    • 一旦重写了dealloc方法,就必须调用[super dealloc],并且放在最后面调用

- (void)dealloc
{
NSLog(@"Person dealloc");
// 注意:super dealloc一定要写到所有代码的最后
// 一定要写在dealloc方法的最后面
[super dealloc];
}

  • 使用注意
    • 不能直接调用dealloc方法
    • 一旦对象被回收了, 它占用的内存就不再可用,坚持使用会导致程序崩溃(野指针错误)

4. 野指针和空指针

  • 只要一个对象被释放了,我们就称这个对象为 “僵尸对象(不能再使用的对象)”
  • 当一个指针指向一个僵尸对象(不可用内存),我们就称这个指针为野指针
  • 只要给一个野指针发送消息就会报错(EXC_BAD_ACCESS错误)
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [[Person alloc] init]; // 执行完引用计数为1

[p release]; // 执行完引用计数为0,实例对象被释放
[p release]; // 此时,p就变成了野指针,再给野指针p发送消息就会报错
[p release];
}
return 0;
}
  • 为了避免给野指针发送消息会报错,一般情况下,当一个对象被释放后我们会将这个对象的指针设置为空指针
  • 空指针
    • 没有指向存储空间的指针(里面存的是nil, 也就是0)
    • 给空指针发消息是没有任何反应的

int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [[Person alloc] init]; // 执行完引用计数为1

[p release]; // 执行完引用计数为0,实例对象被释放
p = nil; // 此时,p变为了空指针
[p release]; // 再给空指针p发送消息就不会报错了
[p release];
}
return 0;
}

5. 内存管理规律

单个对象内存管理规律
  • 谁创建谁release :
    • 如果你通过alloc、new、copy或mutableCopy来创建一个对象,那么你必须调用release或autorelease
  • 谁retain谁release:
    • 只要你调用了retain,就必须调用一次release
  • 总结一下就是
    • 有加就有减
    • 曾经让对象的计数器+1,就必须在最后让对象计数器-1
多个对象内存管理规律

因为多个对象之间往往是联系的,所以管理起来比较复杂。这里用一个玩游戏例子来类比一下。

游戏可以提供给玩家(A类对象) 游戏房间(B类对象)来玩游戏。

  • 只要一个玩家想使用房间(进入房间),就需要对这个房间的引用计数器+1
  • 只要一个玩家不想再使用房间(离开房间),就需要对这个房间的引用计数器-1
  • 只要还有至少一个玩家在用某个房间,那么这个房间就不会被回收,引用计数至少为1




下面来定义两个类 玩家类:Person 和 房间类:Room

房间类:Room,房间类中有房间号

#import <Foundation/Foundation.h>

@interface Room : NSObject
@property int no; // 房间号
@end

玩家类:Person

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

@interface Person : NSObject
{
Room *_room;
}

- (void)setRoom:(Room *)room;

- (Room *)room;
@end

现在我们通过几个玩家使用房间的不同应用场景来逐步深入理解内存管理。

1. 玩家没有使用房间,玩家和房间之间没有联系的情况
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 1.创建两个对象
Person *p = [[Person alloc] init]; // 玩家 p
Room *r = [[Room alloc] init]; // 房间 r
r.no = 888; // 房间号赋值

[r release]; // 释放房间
[p release]; // 释放玩家
}
return 0;
}

上述代码执行完前3行

// 1.创建两个对象
Person *p = [[Person alloc] init]; // 玩家 p
Room *r = [[Room alloc] init]; // 房间 r
r.no = 888; // 房间号赋值

之后在内存中的表现如下图所示:




可见,Room实例对象和Person实例对象之间没有相互联系,所以各自释放不会报错。执行完4、5行代码

[r release];    // 释放房间      
[p release]; // 释放玩家

后,将房间对象和玩家对象各自释放掉,在内存中的表现如下图所示:



最后各自实例对象的内存就会被系统回收

2. 一个玩家使用一个游戏房间,玩家和房间之间相关联的情况
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 1.创建两个对象
Person *p = [[Person alloc] init]; // 玩家 p
Room *r = [[Room alloc] init]; // 房间 r
r.no = 888; // 房间号赋值

// 将房间赋值给玩家,表示玩家在使用房间
// 玩家需要使用这间房,只要玩家在,房间就一定要在
p.room = r; // [p setRoom:r]

[r release]; // 释放房间

// 在这行代码之前,玩家都没有被释放,但是因为玩家还在,那么房间就不能销毁
NSLog(@"-----");

[p release]; // 释放玩家
}
return 0;
}

上边代码执行完前3行的时候和之前在内存中的表现一样,如图


当执行完第4行代码p.room = r;时,因为调用了setter方法,将Room实例对象赋值给了Person的成员变量,不做其他设置的话,在内存中的表现如下图(做法不对):



在调用setter方法的时候,因为Room实例对象多了一个Person对象引用,所以应将Room实例对象的引用计数+1才对,即setter方法应该像下边一样,对room进行一次retain操作。

- (void)setRoom:(Room *)room // room = r
{
// 对房间的引用计数器+1
[room retain];
_room = room;
}

那么执行完第4行代码p.room = r;,在内存中的表现为:




继续执行第5行代码[r release];,释放房间,Room实例对象引用计数-1,在内存中的表现如下图所示:



然后执行第6行代码[p release];,释放玩家。这时候因为玩家不在房间里了,房间也没有用了,所以在释放玩家的时候,要把房间也释放掉,也就是在delloc里边对房间再进行一次release操作。

这样对房间对象来说,每一次retain/alloc操作都对应一次release操作。

- (void)dealloc
{
// 人释放了, 那么房间也需要释放
[_room release];
NSLog(@"%s", __func__);

[super dealloc];
}

那么在内存中的表现最终如下图所示:



最后实例对象的内存就会被系统回收

3. 一个玩家使用一个游戏房间R后,换到另一个游戏房间R2,玩家和房间相关联的情况
int main(int argc, const char * argv[]) {
@autoreleasepool {
// 1.创建两个对象
Person *p = [[Person alloc] init]; // 玩家 p
Room *r = [[Room alloc] init]; // 房间 r
r.no = 888; // 房间号赋值

// 2.将房间赋值给玩家,表示玩家在使用房间
p.room = r; // [p setRoom:r]
[r release]; // 释放房间 r

// 3. 换房
Room *r2 = [[Room alloc] init];
r2.no = 444;
p.room = r2;
[r2 release]; // 释放房间 r2

[p release]; // 释放玩家 p
}
return 0;
}

执行下边几行代码

// 1.创建两个对象
Person *p = [[Person alloc] init]; // 玩家 p
Room *r = [[Room alloc] init]; // 房间 r
r.no = 888; // 房间号赋值

// 2.将房间赋值给玩家,表示玩家在使用房间
p.room = r; // [p setRoom:r]
[r release]; // 释放房间 r

之后的内存表现为:



接着执行换房操作而不进行其他操作的话,

// 3. 换房
Room *r2 = [[Room alloc] init];
r2.no = 444;
p.room = r2;

内存的表现为:



最后执行完

[r2 release];    // 释放房间 r2
[p release]; // 释放玩家 p

内存的表现为:




可以看出房间 r 并没有被释放,这是因为在进行换房的时候,并没有对房间 r 进行释放。所以应在调用setter方法的时候,对之前的变量进行一次release操作。具体setter方法代码如下:

- (void)setRoom:(Room *)room // room = r
{
// 将以前的房间释放掉 -1
[_room release];

// 对房间的引用计数器+1
[room retain];

_room = room;
}
}

这样在执行完p.room = r2;之后就会将 房间 r 释放掉,最终内存表现为:




4. 一个玩家使用一个游戏房间,不再使用游戏房间,将游戏房间释放掉之后,再次使用该游戏房间的情况

int main(int argc, const char * argv[]) {
@autoreleasepool {
// 1.创建两个对象
Person *p = [[Person alloc] init];
Room *r = [[Room alloc] init];
r.no = 888;

// 2.将房间赋值给人
p.room = r; // [p setRoom:r]
[r release]; // 释放房间 r

// 3.再次使用房间 r
p.room = r;
[r release]; // 释放房间 r
[p release]; // 释放玩家 p
}
return 0;
}

执行下面代码

// 1.创建两个对象
Person *p = [[Person alloc] init];
Room *r = [[Room alloc] init];
r.no = 888;

// 2.将房间赋值给人
p.room = r; // [p setRoom:r]
[r release]; // 释放房间 r

之后的内存表现为:



然后再执行p.room = r;,因为setter方法会将之前的Room实例对象先release掉,此时内存表现为:




此时_room、r 已经变成了一个野指针。之后再对野指针 r 发出retain消息,程序就会崩溃。所以我们在进行setter方法的时候,要先判断一下是否是重复赋值,如果是同一个实例对象,就不需要重复进行release和retain。换句话说,如果我们使用的还是之前的房间,那换房的时候就不需要对这个房间再进行release和retain。则setter方法具体代码如下:

- (void)setRoom:(Room *)room // room = r
{
// 只有房间不同才需用release和retain
if (_room != room) { // 0ffe1 != 0ffe1
// 将以前的房间释放掉 -1
[_room release];

// 对房间的引用计数器+1
[room retain];

_room = room;
}
}

因为retain不仅仅会对引用计数器+1, 而且还会返回当前对象,所以上述代码可最终简化成:

- (void)setRoom:(Room *)room // room = r
{
// 只有房间不同才需用release和retain
if (_room != room) { // 0ffe1 != 0ffe1
// 将以前的房间释放掉 -1
[_room release];

_room = [room retain];
}
}

以上就是setter方法的最终形式。

6. @property参数

  • 在成员变量前加上@property,系统就会自动帮我们生成基本的setter/getter方法
@property (nonatomic) int val;
  • 如果在property后边加上retain,系统就会自动帮我们生成getter/setter方法内存管理的代码,但是仍需要我们自己重写dealloc方法
@property(nonatomic, retain) Room *room;
  • 如果在property后边加上assign,系统就不会帮我们生成set方法内存管理的代码,仅仅只会生成普通的getter/setter方法,默认什么都不写就是assign
@property(nonatomic, retain) int val;

7. 自动释放池

当我们不再使用一个对象的时候应该将其空间释放,但是有时候我们不知道何时应该将其释放。为了解决这个问题,Objective-C提供了autorelease方法。

  • autorelease是一种支持引用计数的内存管理方式,只要给对象发送一条autorelease消息,会将对象放到一个自动释放池中,当自动释放池被销毁时,会对池子里面的所有对象做一次release操作

    注意,这里只是发送release消息,如果当时的引用计数(reference-counted)依然不为0,则该对象依然不会被释放。

  • autorelease方法会返回对象本身,且调用完autorelease方法后,对象的计数器不变

Person *p = [Person new];
p = [p autorelease];
NSLog(@"count = %lu", [p retainCount]); // 计数还为1
1. 使用AUTORELEASE有什么好处呢
  • 不用再关心对象释放的时间
  • 不用再关心什么时候调用release
2. AUTORELEASE的原理实质上是什么?

autorelease实际上只是把对release的调用延迟了,对于每一个autorelease,系统只是把该对象放入了当前的autorelease pool中,当该pool被释放时,该pool中的所有对象会被调用release。

3. AUTORELEASE的创建方法
  1. 使用NSAutoreleasePool来创建
NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; // 创建自动释放池
[pool release]; // [pool drain]; 销毁自动释放池
  1. 使用@autoreleasepool创建
@autoreleasepool
{ //开始代表创建自动释放池

} //结束代表销毁自动释放池
4. AUTORELEASE的使用方法

NSAutoreleasePool *autoreleasePool = [[NSAutoreleasePool alloc] init];
Person *p = [[[Person alloc] init] autorelease];
[autoreleasePool drain];
@autoreleasepool
{ // 创建一个自动释放池
Person *p = [[Person new] autorelease];
// 将代码写到这里就放入了自动释放池
} // 销毁自动释放池(会给池子中所有对象发送一条release消息)
5. AUTORELEASE的注意事项
  • 并不是放到自动释放池代码中,都会自动加入到自动释放池
@autoreleasepool {
// 因为没有调用 autorelease 方法,所以对象没有加入到自动释放池
Person *p = [[Person alloc] init];
[p run];
}
  • 在自动释放池的外部发送autorelease 不会被加入到自动释放池中
    • autorelease是一个方法,只有在自动释 放池中调用才有效。
@autoreleasepool {
}
// 没有与之对应的自动释放池, 只有在自动释放池中调用autorelease才会放到释放池
Person *p = [[[Person alloc] init] autorelease];
[p run];

// 正确写法
@autoreleasepool {
Person *p = [[[Person alloc] init] autorelease];
}

// 正确写法
Person *p = [[Person alloc] init];
@autoreleasepool {
[p autorelease];
}

6. 自动释放池的嵌套使用
  • 自动释放池是以栈的形式存在

  • 由于栈只有一个入口, 所以调用autorelease会将对象放到栈顶的自动释放池

    栈顶就是离调用autorelease方法最近的自动释放池


@autoreleasepool { // 栈底自动释放池
@autoreleasepool {
@autoreleasepool { // 栈顶自动释放池
Person *p = [[[Person alloc] init] autorelease];
}
Person *p = [[[Person alloc] init] autorelease];
}
}
  • 自动释放池中不适宜放占用内存比较大的对象
    • 尽量避免对大内存使用该方法,对于这种延迟释放机制,还是尽量少用
    • 不要把大量循环操作放到同一个 @autoreleasepool 之间,这样会造成内存峰值的上升
// 内存暴涨
@autoreleasepool {
for (int i = 0; i < 99999; ++i) {
Person *p = [[[Person alloc] init] autorelease];
}
}

// 内存不会暴涨
for (int i = 0; i < 99999; ++i) {
@autoreleasepool {
Person *p = [[[Person alloc] init] autorelease];
}
}

7. AUTORELEASE错误用法
  • 不要连续调用autorelease
@autoreleasepool {
// 错误写法, 过度释放
Person *p = [[[[Person alloc] init] autorelease] autorelease];
}

  • 调用autorelease后又调用release(错误)
@autoreleasepool {
Person *p = [[[Person alloc] init] autorelease];
[p release]; // 错误写法, 过度释放
}

8. MRC中避免循环retain

定义两个类Person类和Dog类

  • Person类:
#import <Foundation/Foundation.h>
@class Dog;

@interface Person : NSObject
@property(nonatomic, retain)Dog *dog;
@end
  • Dog类:
#import <Foundation/Foundation.h>
@class Person;

@interface Dog : NSObject
@property(nonatomic, retain)Person *owner;
@end

执行以下代码:

int main(int argc, const char * argv[]) {
Person *p = [Person new];
Dog *d = [Dog new];

p.dog = d; // retain
d.owner = p; // retain assign

[p release];
[d release];

return 0;
}

就会出现A对象要拥有B对象,而B对应又要拥有A对象,此时会形成循环retain,导致A对象和B对象永远无法释放

那么如何解决这个问题呢?

  • 不要让A retain B,B retain A
  • 让其中一方不要做retain操作即可
  • 当两端互相引用时,应该一端用retain,一端用assign

四、ARC 自动管理内存(Automatic Reference Counting)

  • Automatic Reference Counting,自动引用计数,即ARC,WWDC2011和iOS5所引入的最大的变革和最激动人心的变化。ARC是新的LLVM 3.0编译器的一项特性,使用ARC,可以说一 举解决了广大iOS开发者所憎恨的手动内存管理的麻烦。
  • 使用ARC后,系统会检测出何时需要保持对象,何时需要自动释放对象,何时需要释放对象,编译器会管理好对象的内存,会在何时的地方插入retain, release和autorelease,通过生成正确的代码去自动释放或者保持对象。我们完全不用担心编译器会出错

1\ ARC的判断原则

ARC判断一个对象是否需要释放不是通过引用计数来进行判断的,而是通过强指针来进行判断的。那么什么是强指针?

  • 强指针
    • 默认所有对象的指针变量都是强指针
    • 被__strong修饰的指针

Person *p1 = [[Person alloc] init];
__strong Person *p2 = [[Person alloc] init];
  • 弱指针
    • 被__weak修饰的指针
__weak  Person *p = [[Person alloc] init];

ARC如何通过强指针来判断?

  • 只要还有一个强指针变量指向对象,对象就会保持在内存中

2. ARC的使用

int main(int argc, const char * argv[]) {
// 不用写release, main函数执行完毕后p会被自动释放
Person *p = [[Person alloc] init];

return 0;
}

3. ARC的注意点

  • 不允许调用对象的 release方法
  • 不允许调用 autorelease方法
  • 重写父类的dealloc方法时,不能再调用 [super dealloc];

4. ARC下单对象内存管理

  • 局部变量释放对象随之被释放
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [[Person alloc] init];
} // 执行到这一行局部变量p释放
// 由于没有强指针指向对象, 所以对象也释放
return 0;
}

  • 清空指针对象随之被释放
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *p = [[Person alloc] init];
p = nil; // 执行到这一行, 由于没有强指针指向对象, 所以对象被释放
}
return 0;
}
  • 默认清空所有指针都是强指针
int main(int argc, const char * argv[]) {
@autoreleasepool {
// p1和p2都是强指针
Person *p1 = [[Person alloc] init];
__strong Person *p2 = [[Person alloc] init];
}
return 0;
}

  • 弱指针需要明确说明
    • 注意: 千万不要使用弱指针保存新创建的对象
int main(int argc, const char * argv[]) {
@autoreleasepool {
// p是弱指针, 对象会被立即释放
__weak Person *p1 = [[Person alloc] init];
}
return 0;
}

5. ARC下多对象内存管理

  • ARC和MRC一样, 想拥有某个对象必须用强指针保存对象, 但是不需要在dealloc方法中release
@interface Person : NSObject
// MRC写法
//@property (nonatomic, retain) Dog *dog;

// ARC写法
@property (nonatomic, strong) Dog *dog;
@end

6. ARC下@property参数

  • strong : 用于OC对象,相当于MRC中的retain
  • weak : 用于OC对象,相当于MRC中的assign
  • assign : 用于基本数据类型,跟MRC中的assign一样

7. ARC下循环引用问题

  • ARC和MRC一样,如果A拥有B,B也拥有A,那么必须一方使用弱指针

@interface Person : NSObject
@property (nonatomic, strong) Dog *dog;
@end

@interface Dog : NSObject
// 错误写法, 循环引用会导致内存泄露
//@property (nonatomic, strong) Person *owner;

// 正确写法, 当如果保存对象建议使用weak
@property (nonatomic, weak) Person *owner;
@end




作者:NJKNJK
链接:https://www.jianshu.com/p/af3d7700f280


收起阅读 »

『Blocks』基本使用

本文用来介绍 iOS开发中 『Blocks』的基本使用。通过本文您将了解到:什么是 BlocksBlocks 变量语法Blocks 变量的声明与赋值Blocks 变量截获局部变量值特性使用 __block 说明符Blocks 变量的循环引用以及如何避...
继续阅读 »

本文用来介绍 iOS开发中 『Blocks』的基本使用。通过本文您将了解到:

  1. 什么是 Blocks
  2. Blocks 变量语法
  3. Blocks 变量的声明与赋值
  4. Blocks 变量截获局部变量值特性
  5. 使用 __block 说明符
  6. Blocks 变量的循环引用以及如何避免

1. 什么是 Blocks ?

一句话总结:Blocks 是带有 局部变量 的 匿名函数(不带名称的函数)。

Blocks 也被称作 闭包代码块。展开来讲,Blocks 就是一个代码块,把你想要执行的代码封装在这个代码块里,等到需要的时候再去调用。

下边我们先来理解 局部变量匿名函数 的含义。

1.1 局部变量

在 C 语言中,定义在函数内部的变量称为 局部变量。它的作用域仅限于函数内部, 离开该函数后就是无效的,再使用就会报错。

int x, y; // x,y 为全局变量

int fun(int a) {
int b, c; //a,b,c 为局部变量
return a+b+c;
}

int main() {
int m, n; // m,n 为局部变量
return 0;
}

从上边的代码中,我们可以看出:

  1. 我们在开始位置定义了变量 x 和 变量 y。 x 和 y 都是全局变量。它们的作用域默认是整个程序,也就是所有的源文件,包括 .c 和 .h 文件。
  2. 而我们在 fun() 函数中定义了变量 a、变量 b、变量 c。它们的作用域是 fun() 函数。只能在 fun() 函数内部使用,离开 fun() 函数就是无效的。
  3. 同理,main() 函数中的变量 m、变量 n 也只能在 main() 函数内部使用。

1.2 匿名函数

匿名函数指的是不带有名称的函数。但是 C 语言中不允许存在这样的函数。

在 C 语言中,一个普通的函数长这样子:


int fun(int a);

fun 就是这个函数的名称,在调用的时候必须要使用该函数的名称 fun 来调用。

int result = fun(10);
在 C 语言中,我们还可以通过函数指针来直接调用函数。但是在给函数指针赋值的时候,同样也是需要知道函数的名称。

int (*funPtr)(int) = &fun;
int result = (*funPtr)(10);

而我们通过 Blocks,可以直接使用函数,不用给函数命名。


2. Blocks 变量语法

我们使用 ^ 运算符来声明 Blocks 变量,并将 Blocks 对象主体部分包含在 {} 中,同时,句尾加 ; 表示结尾。

下边来看一个官方的示例:

int multiplier = 7;
int (^ myBlock)(int)= ^(int num) {
return num * multiplier;
};
这个 Blocks 示例中,myBlock 是声明的块对象,返回类型是 整型值,myBlock 块对象有一个 参数,参数类型为整型值,参数名称为 num。myBlock 块对象的 主体部分 为 return num * multiplier;,包含在 {} 中。

参考上面的示例,我们可以将 Blocks 表达式语法表述为:

^ 返回值类型 (参数列表) { 表达式 };

例如,我们可以写出这样的 Block 语法:

^ int (int count) { return count + 1; };

Blocks 规定可以省略好多项目。例如:返回值类型参数列表。如果用不到,都可以省略。

2.1 省略返回值类型:^ (参数列表) { 表达式 };

上边的 Blocks 语法就可以写为:

^ (int count) { return count + 1; };

表达式中,return 语句使用的是 count + 1 语句的返回类型。如果表达式中有多个 return 语句,则所有 return 语句的返回值类型必须一致。

如果表达式中没有 return 语句,则可以用 void 表示,或者也省略不写。代码如下:。

^ void (int count)  { printf("%d\n", count); };    // 返回值类型使用 void
^ (int count) { printf("%d\n", count); }; // 省略返回值类型

2.2 省略参数列表 ^ 返回值类型 (void) { 表达式 };

如果表达式中,没有使用参数,则用 void 表示,也可以省略 void。


^ int (void) { return 1; };    // 参数列表使用 void
^ int { return 1; }; // 省略参数列表类型

2.3 省略返回值类型、参数列表:^ { 表达式 };

从上边 2.1 中可以看出,无论有无返回值,都可以省略返回值类型。并且,从 2.2 中可以看出,如果不需要参数列表的话,也可以省略参数列表。则代码可以简化为:

^ { printf("Blocks"); };

3. Blocks 变量的声明与赋值

3.1 Blocks 变量的声明与赋值语法

Blocks 变量的声明与赋值语法可以总结为:

返回值类型 (^变量名) (参数列表) = Blocks 表达式

注意:此处返回值类型不可以省略,若无返回值,则使用 void 作为返回值类型。

例如,定义一个变量名为 blk 的 Blocks 变量:


int (^blk) (int)  = ^(int count) { return count + 1; };
int (^blk1) (int); // 声明变量名为 blk1 的 Blocks 变量
blk1 = blk; // 将 blk 赋值给 blk1

Blocks 变量的声明语法有点复杂,其实我们可以和 C 语言函数指针的声明类比着来记。

Blocks 变量的声明就是把声明函数指针类型的变量 * 变为 ^

//  C 语言函数指针声明与赋值
int func (int count) {
return count + 1;
}
int (*funcptr)(int) = &func;

// Blocks 变量声明与赋值
int (^blk) (int) = ^(int count) { return count + 1; };

3.2 Blocks 变量的声明与赋值的使用

3.2.1 作为局部变量:返回值类型 (^变量名) (参数列表) = 返回值类型 (参数列表) { 表达式 };

我们可以把 Blocks 变量作为局部变量,在一定范围内(函数、方法内部)使用。

// Blocks 变量作为本地变量
- (void)useBlockAsLocalVariable {
void (^myLocalBlock)(void) = ^{
NSLog(@"useBlockAsLocalVariable");
};

myLocalBlock();
}
3.2.2 作为带有 property 声明的成员变量:@property (nonatomic, copy) 返回值类型 (^变量名) (参数列表);

作用类似于 delegate,实现 Blocks 回调。

/* Blocks 变量作为带有 property 声明的成员变量 */
@property (nonatomic, copy) void (^myPropertyBlock) (void);

// Blocks 变量作为带有 property 声明的成员变量
- (void)useBlockAsProperty {
self.myPropertyBlock = ^{
NSLog(@"useBlockAsProperty");
};

self.myPropertyBlock();
}

3.2.3 作为 OC 方法参数:- (void)someMethodThatTaksesABlock:(返回值类型 (^)(参数列表)) 变量名;

可以把 Blocks 变量作为 OC 方法中的一个参数来使用,通常 blocks 变量写在方法名的最后。

// Blocks 变量作为 OC 方法参数
- (void)someMethodThatTakesABlock:(void (^)(NSString *)) block {
block(@"someMethodThatTakesABlock:");
}
3.2.4 调用含有 Block 参数的 OC方法:[someObject someMethodThatTakesABlock:^返回值类型 (参数列表) { 表达式}];
// 调用含有 Block 参数的 OC方法
- (void)useBlockAsMethodParameter {
[self someMethodThatTakesABlock:^(NSString *str) {
NSLog(@"%@",str);
}];
}

通过 3.2.3 和 3.2.4 中,Blocks 变量作为 OC 方法参数的调用,我们同样可以实现类似于 delegate 的作用,即 Blocks 回调(后边应用场景中会讲)。

3.2.5 作为 typedef 声明类型:
typedef 返回值类型 (^声明名称)(参数列表);
声明名称 变量名 = ^返回值类型(参数列表) { 表达式 };
// Blocks 变量作为 typedef 声明类型
- (void)useBlockAsATypedef {
typedef void (^TypeName)(void);

// 之后就可以使用 TypeName 来定义无返回类型、无参数列表的 block 了。
TypeName myTypedefBlock = ^{
NSLog(@"useBlockAsATypedef");
};

myTypedefBlock();
}

4. Blocks 变量截获局部变量值特性

先来看一个例子。

// 使用 Blocks 截获局部变量值
- (void)useBlockInterceptLocalVariables {
int a = 10, b = 20;

void (^myLocalBlock)(void) = ^{
printf("a = %d, b = %d\n",a, b);
};

myLocalBlock(); // 打印结果:a = 10, b = 20

a = 20;
b = 30;

myLocalBlock(); // 打印结果:a = 10, b = 20
}

为什么两次打印结果都是 a = 10, b = 20

明明在第一次调用 myLocalBlock(); 之后已经重新给变量 a、变量 b 赋值了,为什么第二次调用 myLocalBlock(); 的时候,使用的还是之前对应变量的值?

因为 Block 语法的表达式使用的是它之前声明的局部变量 a、变量 b。Blocks 中,Block 表达式截获所使用的局部变量的值,保存了该变量的瞬时值。所以在第二次执行 Block 表达式时,即使已经改变了局部变量 a 和 b 的值,也不会影响 Block 表达式在执行时所保存的局部变量的瞬时值。

这就是 Blocks 变量截获局部变量值的特性。

5. 使用 __block 说明符

实际上,在使用 Block 表达式的时候,只能使用保存的局部变量的瞬时值,并不能直接对其进行改写。直接修改编译器会直接报错,如下图所示。



那么如果,我们想要该写 Block 表达式中截获的局部变量的值,该怎么办呢?

如果,我们想在 Block 表达式中,改写 Block 表达式之外声明的局部变量,需要在该局部变量前加上 __block 的修饰符。

这样我们就能实现:在 Block 表达式中,为表达式外的局部变量赋值。


// 使用 __block 说明符修饰,更改局部变量值
- (void)useBlockQualifierChangeLocalVariables {
__block int a = 10, b = 20;
void (^myLocalBlock)(void) = ^{
a = 20;
b = 30;

printf("a = %d, b = %d\n",a, b); // 打印结果:a = 20, b = 30
};

myLocalBlock();
}

可以看到,使用 __block 说明符修饰之后,我们在 Block表达式中,成功的修改了局部变量值。

6. Blocks 变量的循环引用以及如何避免

从上文中我们知道 Block 会对引用的局部变量进行持有。同样,如果 Block 也会对引用的对象进行持有,从而会导致相互持有,引起循环引用。


/* —————— retainCycleBlcok.m —————— */   
#import <Foundation/Foundation.h>
#import "Person.h"
int main() {
Person *person = [[Person alloc] init];
person.blk = ^{
NSLog(@"%@",person);
};

return 0;
}


/* —————— Person.h —————— */
#import <Foundation/Foundation.h>

typedef void(^myBlock)(void);

@interface Person : NSObject
@property (nonatomic, copy) myBlock blk;
@end


/* —————— Person.m —————— */
#import "Person.h"

@implementation Person

@end

上面 retainCycleBlcok.m 中 main() 函数的代码会导致一个问题:person 持有成员变量 myBlock blk,而 blk 也同时持有成员变量 person,两者互相引用,永远无法释放。就造成了循环引用问题。

那么,如何来解决这个问题呢?

6.1 ARC 下,通过 __weak 修饰符来消除循环引用

在 ARC 下,可声明附有 __weak 修饰符的变量,并将对象赋值使用。

int main() {
Person *person = [[Person alloc] init];
__weak typeof(person) weakPerson = person;

person.blk = ^{
NSLog(@"%@",weakPerson);
};

return 0;
}

这样,通过 __weak,person 持有成员变量 myBlock blk,而 blk 对 person 进行弱引用,从而就消除了循环引用。

6.2 MRC 下,通过 __block 修饰符来消除循环引用

MRC 下,是不支持 weak 修饰符的。但是我们可以通过 block 来消除循环引用。

int main() {
Person *person = [[Person alloc] init];
__block typeof(person) blockPerson = person;

person.blk = ^{
NSLog(@"%@", blockPerson);
};

return 0;
}

通过 __block 引用的 blockPerson,是通过指针的方式来访问 person,而没有对 person 进行强引用,所以不会造成循环引用。




作者:NJKNJK
链接:https://www.jianshu.com/p/c5561abe9dd8


收起阅读 »

2021 提升Android开发效率的实战技巧

一 泛型 + 反射 我们创建Activity的时候 需要先设置布局setContentView(R.layout..) 如果使用了ViewModel,还得给每个Activity创建ViewModel. 如果项目中Activity过多,无疑是...
继续阅读 »

一 泛型 + 反射


我们创建Activity的时候



  1. 需要先设置布局setContentView(R.layout..)

  2. 如果使用了ViewModel,还得给每个Activity创建ViewModel.


如果项目中Activity过多,无疑是写很多模板代码的,借助Java的泛型机制,我们可以在BaseAct,封装上述逻辑。


1.1 示例


先创建BaseAct


abstract class BaseAct<B : ViewDataBinding, VM : ViewModel> : AppCompatActivity() {
private var mBinding: B? = null
private lateinit var mModel: VM
abstract val layoutId: Int
abstract fun doBusiness(savedInstanceState: Bundle?)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 创建View
setContentView(createViewBinding().root)
// 创建ViewModel
createViewModel()
doBusiness(savedInstanceState)
}

fun getB(): B {
return mBinding!!
}

fun getVM(): VM {
return mModel
}

private fun createViewBinding(): B {
mBinding = DataBindingUtil.inflate(LayoutInflater.from(this), layoutId, null, false)
mBinding!!.lifecycleOwner = this
return mBinding!!
}

private fun createViewModel() {
val type = javaClass.genericSuperclass!! as ParameterizedType
val argsType = type.actualTypeArguments
val modelClass: Class<VM> = argsType[1] as Class<VM>
val model = ViewModelProvider(this).get(modelClass)
mModel = model
}

override fun onDestroy() {
super.onDestroy()
mBinding?.unbind()
mBinding = null
}
}

创建一个LoginAct的时候,可以这样写


// 声明泛型类
class LoginAct : BaseAct<LoginActBinding, LoginActViewModel>() {
override val layoutId: Int = R.layout.login_act

override fun doBusiness(savedInstanceState: Bundle?) {
// 逻辑处理
getVm() // 获取到的是 LoginActViewModel
}
}
class LoginActViewModel : ViewModel() {

}

二 一次生成多个文件


上面LoginAct的创建。我们一般得做以下几个步骤



  1. 创建一个xml布局

  2. new 一个 Kotlin Class/File创建LoginViewModel

  3. new 一个 Kotlin Class/File创建LoginAct

  4. LoginAct 继承 BaseAct,重写方法


通过 templates模板,可以把上面步骤简化。


2.1 as 版本4.1之前


使用的是FreeMarker模板引擎


2.1.1 把模板放到对应目录



  1. 新建文件夹mvvm_templates,放到目录**android Studio\plugins\android\lib\templates\activities **

  2. 把以下文件放到mvvm_templates文件夹里


image.png


2.1.2 模板文件介绍


mvvm_templates
|-- root // 文件
|-- src
|-- app_package
|-- xx.kt // 期望生成的kt文件
|-- xx.java // 期望生成的java文件
|-- ...
|-- res // 资源模板
|-- xx.xml.ftl // 期望生成的xml
|-- ...
|-- globals.xml.ftl
|-- recipe.xml.ftl // 管理所有的文件声明
|-- template.xml // 模板控制台

2.1.3 使用方法



  • 上面的ftl 描述执行模板的参数和指令

  • as启动后,Android Studio 会解析“  /templates ”文件夹的内容,向“ **New -> **”菜单界面添加模板名,当点击对应模板名,Android Studio会读取“ template.xml ”的内容,构建UI等。


image.png


image.png 我 as 升级了,无法截我自己的配置页面图,原理是一样的,你的模板配置了哪些选项,在上图中就可以选择。



  1. 我的mvvm_templates 模板下载地址


这是我自己的配置,大家可以拿去参考修改。


2.2 as 版本4.1后


从 Android Studio 4.1 开始,Google 停止了对自定义 FreeMarker 模板的支持。 该功能对于我来讲是非常实用的,所以我在github上找到了另外一种解决方案1解决方案2


很多人在谷歌的问题追踪里进行反馈,但到目前还在等待官方支持。


三 一次生成一个文件


Edit File Templates,创建单个xml、单个文件、文件头等模板


3.1 创建xml布局


image.png 步骤还是挺繁琐的,也需要点几下,创建出来的布局文件只有1个根布局。


通过下面模板布局,可以简化上面步骤,并且可以设置一些常用的脚手架布局。


3.2 创建xml模板布局


3.2.1 配置模板



  1. 编辑模板

  2. 创建一个file

  3. 定义模板名字

  4. 定义文件后缀

  5. 把你的模板布局copy进去

  6. 完成


image.png


3.2.2 使用模板


刚才配置的模板就会在这里显示,点击后就会生成对应的布局。 image.png


配置布局会自动填充进来,可以根据不同场景,定义多种不同的模板。 image.png


3.3 创建kt文件模板


image.png 步骤和上面创建xml模板是一样的,只是该下文件后缀名。这里多了个File Header,创建步骤如下。


3.4 创建File Header


image.png


四 单个文件快捷输出


在AS 设置里 Live Templates


4.1 示例


如果我想让红色图片居中显示,必须得添加4行约束属性,这些属性对于咱们开发来讲是经常要写的。如果在xml里输入 cc 按下回车,就能生成这4行代码,是不是能节约点时间? image.png


4.2 配置


建议分组管理。



  • 在xml里的快捷键单独创建一个组。

  • 在kotlin的快捷键单独创建一个组。


image.png


image.png


4.3 使用


我设置了



  • cc显示4个约束属性

  • tt显示app:layout_constraintTop_toTopOf="parent"

  • 同样,在kotlin中,在java中,比如日志打印、if判断、初始化变量、更多使用场景等你挖掘。


布局快捷键.gif


4.4 我自己的模板


image.png



最终as会在该路径下生成上面我们的配置模板: C:\Users\userName\AppData\Roaming\Google\AndroidStudio4.1\templates



五 AS 常用插件


5.1 WiFi连接手机调试


image.png


5.2 Translation 翻译英文


image.png


5.3 其他



  • Alibaba Java Coding Guidelines 阿里Java代码规范

  • CodeGlance 在右边可以预览代码结构,实现快速定位

  • Database Navigator 数据库调试

收起阅读 »

Flutter 入门与实战:让模拟器和和邮递员(Postman)聊聊天

前言 上一篇Flutter 入门与实战(五十五):和 Provider 一起玩 WebSocket我们讲了使用 socket_client_io 和 StreamProvider实现 WebSocket 通讯。本篇延续上一篇,来讲一下如何实现与其他用户进行即...
继续阅读 »

前言


上一篇Flutter 入门与实战(五十五):和 Provider 一起玩 WebSocket我们讲了使用 socket_client_io 和 StreamProvider实现 WebSocket 通讯。本篇延续上一篇,来讲一下如何实现与其他用户进行即时聊天。


Socket 消息推送


在 与服务端Socket 通讯中,调用 socket.emit 方法时默认发送消息都是给当前连接的 socket的,如果要实现发送消息给其他用户,服务端需要做一下改造。具体的做法如下:



  • 在建立连接后,客户多发送消息将用户唯一标识符(例如用户名或 userId)与连接的 socket 对象进行绑定。

  • 当其他用户发送消息给该用户时,找到该用户绑定的 socket 对象,再通过该 socketemit 方法发送消息就可以搞定了。


因此客户端需要发送一个注册消息到服务端以便与用户绑定,同时还应该有一个注销消息,以解除绑定(可选的,也可以通过断开连接来自动解除绑定)。整个聊天过程的时序图如下:


时序图.png


服务端代码已经好了,采用了一个简单的对象来存储用户相关的未发送消息和 socket 对象。可以到后端代码仓库拉取最新代码,


消息格式约定


Socket 可以发送字符串或Json 对象,这里我们约定消息聊天为 Json 对象,字段如下:



  • fromUserId:消息来源用户 id

  • toUserId:接收消息用户 id

  • contentType:消息类型,方便发送文本、图片、语音、视频等消息。目前只做了文本消息,其他消息其实可以在 content 中传对应的资源 id 后由App 自己处理就好了。

  • content:消息内容。


StreamSocket 改造


上一篇的 StreamSocket 改造我们只能发送字符串,为了扩大适用范围,将该类改造成泛型。这里需要注意,Socketemit 的数据会调用对象的 toJson 将对象转为 Json 对象发送,因此泛型的类需要实现 Map<String dynamic> toJson 方法。同时增加了如下属性和方法:



  • recvEvent:接收事件的名称

  • regsiter:注册方法,将用户 id发送到服务端与 socket 绑定,可以理解为上线通知;

  • unregister:注销方法,将用户 id 发送到服务端与 socket解绑,可以理解为下线通知。


class StreamSocket<T> {
final _socketResponse = StreamController<T>();

Stream<T> get getResponse => _socketResponse.stream;

final String host;
final int port;
late final Socket _socket;
final String recvEvent;

StreamSocket(
{required this.host, required this.port, required this.recvEvent}) {
_socket = SocketIO.io('ws://$host:$port', <String, dynamic>{
'transports': ['websocket'],
'autoConnect': true,
'forceNew': true
});
}

void connectAndListen() {
_socket.onConnect((_) {
debugPrint('connected');
});

_socket.onConnectTimeout((data) => debugPrint('timeout'));
_socket.onConnectError((error) => debugPrint(error.toString()));
_socket.onError((error) => debugPrint(error.toString()));
_socket.on(recvEvent, (data) {
_socketResponse.sink.add(data);
});
_socket.onDisconnect((_) => debugPrint('disconnect'));
}

void regsiter(String userId) {
_socket.emit('register', userId);
}

void unregsiter(String userId) {
_socket.emit('unregister', userId);
}

void sendMessage(String event, T message) {
_socket.emit(event, message);
}

void close() {
_socketResponse.close();
_socket.disconnect().close();
}
}

聊天页面


新建一个 chat_with_user.dart 文件,实现聊天相关的代码,其中ChatWithUserPageStatefulWidget,以便在State 的生命周期管理 Socket的连接,注册和注销等操作。目前我们写死了 App 端的用户是 user1,发送消息给 user2


class _ChatWithUserPageState extends State<ChatWithUserPage> {
late final StreamSocket<Map<String, dynamic>> streamSocket;

@override
void initState() {
super.initState();
streamSocket =
StreamSocket(host: '127.0.0.1', port: 3001, recvEvent: 'chat');
streamSocket.connectAndListen();
streamSocket.regsiter('user1');
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('即时聊天'),
),
body: Stack(
alignment: Alignment.bottomCenter,
children: [
StreamProvider<Map<String, dynamic>?>(
create: (context) => streamSocket.getResponse,
initialData: null,
child: StreamDemo(),
),
ChangeNotifierProvider<MessageModel>(
child: MessageReplyBar(messageSendHandler: (message) {
Map<String, String> json = {
'fromUserId': 'user1',
'toUserId': 'user2',
'contentType': 'text',
'content': message
};
streamSocket.sendMessage('chat', json);
}),
create: (context) => MessageModel(),
),
],
),
);
}

@override
void dispose() {
streamSocket.unregsiter('user1');
streamSocket.close();
super.dispose();
}
}

其他的和上一篇基本类似,只是消息对象由 String换成了 Map<String, dynamic>


调试


消息的对话界面本篇先不涉及,下一篇我们再来介绍。现在来看一下如何进行调试。目前 PostMan 的8.x 版本已经支持 WebSocket 调试了,我们拿PostMan 和手机模拟器进行联调。Postman 的 WebSocket 调试界面如下: image.png 使用起来比较简单,这里我们已经完成了如下操作:



  • 注册:使用 user2注册

  • 设置发送消息为 json,消息事件(event)为 chat,以便和 app、服务端 保持一致。


现在来看看调试效果怎么样(PostMan 调起来有点手忙脚乱?)?


屏幕录制2021-08-19 下午9.35.45.gif


可以看到模拟器和 PostMan 直接的通讯是正常的。




收起阅读 »

iOS 专用图层 六

6.8 CAEmitterLayer在iOS 5中,苹果引入了一个新的CALayer子类叫做CAEmitterLayer。CAEmitterLayer是一个高性能的粒子引擎,被用来创建实时例子动画如:烟雾,火,雨等等这些效果。CAEmitterLayer看上去...
继续阅读 »

6.8 CAEmitterLayer

在iOS 5中,苹果引入了一个新的CALayer子类叫做CAEmitterLayerCAEmitterLayer是一个高性能的粒子引擎,被用来创建实时例子动画如:烟雾,火,雨等等这些效果。

CAEmitterLayer看上去像是许多CAEmitterCell的容器,这些CAEmitierCell定义了一个例子效果。你将会为不同的例子效果定义一个或多个CAEmitterCell作为模版,同时CAEmitterLayer负责基于这些模版实例化一个粒子流。一个CAEmitterCell类似于一个CALayer:它有一个contents属性可以定义为一个CGImage,另外还有一些可设置属性控制着表现和行为。我们不会对这些属性逐一进行详细的描述,你们可以在CAEmitterCell类的头文件中找到。

我们来举个例子。我们将利用在一圆中发射不同速度和透明度的粒子创建一个火爆炸的效果。清单6.13包含了生成爆炸的代码。图6.13是运行结果

清单6.13 用CAEmitterLayer创建爆炸效果

#import "ViewController.h"
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@end


@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//create particle emitter layer
CAEmitterLayer *emitter = [CAEmitterLayer layer];
emitter.frame = self.containerView.bounds;
[self.containerView.layer addSublayer:emitter];

//configure emitter
emitter.renderMode = kCAEmitterLayerAdditive;
emitter.emitterPosition = CGPointMake(emitter.frame.size.width / 2.0, emitter.frame.size.height / 2.0);

//create a particle template
CAEmitterCell *cell = [[CAEmitterCell alloc] init];
cell.contents = (__bridge id)[UIImage imageNamed:@"Spark.png"].CGImage;
cell.birthRate = 150;
cell.lifetime = 5.0;
cell.color = [UIColor colorWithRed:1 green:0.5 blue:0.1 alpha:1.0].CGColor;
cell.alphaSpeed = -0.4;
cell.velocity = 50;
cell.velocityRange = 50;
cell.emissionRange = M_PI * 2.0;

//add particle template to emitter
emitter.emitterCells = @[cell];
}
@end

图6.13 火焰爆炸效果

CAEMitterCell的属性基本上可以分为三种:

  • 这种粒子的某一属性的初始值。比如,color属性指定了一个可以混合图片内容颜色的混合色。在示例中,我们将它设置为桔色。
  • 例子某一属性的变化范围。比如emissionRange属性的值是2π,这意味着例子可以从360度任意位置反射出来。如果指定一个小一些的值,就可以创造出一个圆锥形
  • 指定值在时间线上的变化。比如,在示例中,我们将alphaSpeed设置为-0.4,就是说例子的透明度每过一秒就是减少0.4,这样就有发射出去之后逐渐小时的效果。

CAEmitterLayer的属性它自己控制着整个例子系统的位置和形状。一些属性比如birthRatelifetimecelocity,这些属性在CAEmitterCell中也有。这些属性会以相乘的方式作用在一起,这样你就可以用一个值来加速或者扩大整个例子系统。其他值得提到的属性有以下这些:

  • preservesDepth,是否将3D例子系统平面化到一个图层(默认值)或者可以在3D空间中混合其他的图层
  • renderMode,控制着在视觉上粒子图片是如何混合的。你可能已经注意到了示例中我们把它设置为kCAEmitterLayerAdditive,它实现了这样一个效果:合并例子重叠部分的亮度使得看上去更亮。如果我们把它设置为默认的kCAEmitterLayerUnordered,效果就没那么好看了(见图6.14).

图6.14

图6.14 禁止混色之后的火焰粒子

6.9 CAEAGLLayer

当iOS要处理高性能图形绘制,必要时就是OpenGL。应该说它应该是最后的杀手锏,至少对于非游戏的应用来说是的。因为相比Core Animation和UIkit框架,它不可思议地复杂。

OpenGL提供了Core Animation的基础,它是底层的C接口,直接和iPhone,iPad的硬件通信,极少地抽象出来的方法。OpenGL没有对象或是图层的继承概念。它只是简单地处理三角形。OpenGL中所有东西都是3D空间中有颜色和纹理的三角形。用起来非常复杂和强大,但是用OpenGL绘制iOS用户界面就需要很多很多的工作了。

为了能够以高性能使用Core Animation,你需要判断你需要绘制哪种内容(矢量图形,例子,文本,等等),但后选择合适的图层去呈现这些内容,Core Animation中只有一些类型的内容是被高度优化的;所以如果你想绘制的东西并不能找到标准的图层类,想要得到高性能就比较费事情了。

因为OpenGL根本不会对你的内容进行假设,它能够绘制得相当快。利用OpenGL,你可以绘制任何你知道必要的集合信息和形状逻辑的内容。所以很多游戏都喜欢用OpenGL(这些情况下,Core Animation的限制就明显了:它优化过的内容类型并不一定能满足需求),但是这样依赖,方便的高度抽象接口就没了。

在iOS 5中,苹果引入了一个新的框架叫做GLKit,它去掉了一些设置OpenGL的复杂性,提供了一个叫做CLKViewUIView的子类,帮你处理大部分的设置和绘制工作。前提是各种各样的OpenGL绘图缓冲的底层可配置项仍然需要你用CAEAGLLayer完成,它是CALayer的一个子类,用来显示任意的OpenGL图形。

大部分情况下你都不需要手动设置CAEAGLLayer(假设用GLKView),过去的日子就不要再提了。特别的,我们将设置一个OpenGL ES 2.0的上下文,它是现代的iOS设备的标准做法。

尽管不需要GLKit也可以做到这一切,但是GLKit囊括了很多额外的工作,比如设置顶点和片段着色器,这些都以类C语言叫做GLSL自包含在程序中,同时在运行时载入到图形硬件中。编写GLSL代码和设置EAGLayer没有什么关系,所以我们将用GLKBaseEffect类将着色逻辑抽象出来。其他的事情,我们还是会有以往的方式。

在开始之前,你需要将GLKit和OpenGLES框架加入到你的项目中,然后就可以实现清单6.14中的代码,里面是设置一个GAEAGLLayer的最少工作,它使用了OpenGL ES 2.0 的绘图上下文,并渲染了一个有色三角(见图6.15).

清单6.14 用CAEAGLLayer绘制一个三角形

#import "ViewController.h"
#import
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *glView;
@property (nonatomic, strong) EAGLContext *glContext;
@property (nonatomic, strong) CAEAGLLayer *glLayer;
@property (nonatomic, assign) GLuint framebuffer;
@property (nonatomic, assign) GLuint colorRenderbuffer;
@property (nonatomic, assign) GLint framebufferWidth;
@property (nonatomic, assign) GLint framebufferHeight;
@property (nonatomic, strong) GLKBaseEffect *effect;

@end

@implementation ViewController

- (void)setUpBuffers
{
//set up frame buffer
glGenFramebuffers(1, &_framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);

//set up color render buffer
glGenRenderbuffers(1, &_colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorRenderbuffer);
[self.glContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.glLayer];
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_framebufferWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_framebufferHeight);

//check success
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
NSLog(@"Failed to make complete framebuffer object: %i", glCheckFramebufferStatus(GL_FRAMEBUFFER));
}
}

- (void)tearDownBuffers
{
if (_framebuffer) {
//delete framebuffer
glDeleteFramebuffers(1, &_framebuffer);
_framebuffer = 0;
}

if (_colorRenderbuffer) {
//delete color render buffer
glDeleteRenderbuffers(1, &_colorRenderbuffer);
_colorRenderbuffer = 0;
}
}

- (void)drawFrame {
//bind framebuffer & set viewport
glBindFramebuffer(GL_FRAMEBUFFER, _framebuffer);
glViewport(0, 0, _framebufferWidth, _framebufferHeight);

//bind shader program
[self.effect prepareToDraw];

//clear the screen
glClear(GL_COLOR_BUFFER_BIT); glClearColor(0.0, 0.0, 0.0, 1.0);

//set up vertices
GLfloat vertices[] = {
-0.5f, -0.5f, -1.0f, 0.0f, 0.5f, -1.0f, 0.5f, -0.5f, -1.0f,
};

//set up colors
GLfloat colors[] = {
0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f,
};

//draw triangle
glEnableVertexAttribArray(GLKVertexAttribPosition);
glEnableVertexAttribArray(GLKVertexAttribColor);
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 0, vertices);
glVertexAttribPointer(GLKVertexAttribColor,4, GL_FLOAT, GL_FALSE, 0, colors);
glDrawArrays(GL_TRIANGLES, 0, 3);

//present render buffer
glBindRenderbuffer(GL_RENDERBUFFER, _colorRenderbuffer);
[self.glContext presentRenderbuffer:GL_RENDERBUFFER];
}

- (void)viewDidLoad
{
[super viewDidLoad];
//set up context
self.glContext = [[EAGLContext alloc] initWithAPI: kEAGLRenderingAPIOpenGLES2];
[EAGLContext setCurrentContext:self.glContext];

//set up layer
self.glLayer = [CAEAGLLayer layer];
self.glLayer.frame = self.glView.bounds;
[self.glView.layer addSublayer:self.glLayer];
self.glLayer.drawableProperties = @{kEAGLDrawablePropertyRetainedBacking:@NO, kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8};

//set up base effect
self.effect = [[GLKBaseEffect alloc] init];

//set up buffers
[self setUpBuffers];

//draw frame
[self drawFrame];
}

- (void)viewDidUnload
{
[self tearDownBuffers];
[super viewDidUnload];
}

- (void)dealloc
{
[self tearDownBuffers];
[EAGLContext setCurrentContext:nil];
}
@end

图6.15

图6.15 用OpenGL渲染的CAEAGLLayer图层

在一个真正的OpenGL应用中,我们可能会用NSTimerCADisplayLink周期性地每秒钟调用-drawRrame方法60次,同时会将几何图形生成和绘制分开以便不会每次都重新生成三角形的顶点(这样也可以让我们绘制其他的一些东西而不是一个三角形而已),不过上面这个例子已经足够演示了绘图原则了。


收起阅读 »

iOS 专用图层 五

6.6 CAScrollLayer对于一个未转换的图层,它的bounds和它的frame是一样的,frame属性是由bounds属性自动计算而出的,所以更改任意一个值都会更新其他值。但是如果你只想显示一个大图层里面的一小部分呢。比如说,你可能有一个很大的图片,...
继续阅读 »

6.6 CAScrollLayer

对于一个未转换的图层,它的bounds和它的frame是一样的,frame属性是由bounds属性自动计算而出的,所以更改任意一个值都会更新其他值。

但是如果你只想显示一个大图层里面的一小部分呢。比如说,你可能有一个很大的图片,你希望用户能够随意滑动,或者是一个数据或文本的长列表。在一个典型的iOS应用中,你可能会用到UITableView或是UIScrollView,但是对于独立的图层来说,什么会等价于刚刚提到的UITableViewUIScrollView呢?

在第二章中,我们探索了图层的contentsRect属性的用法,它的确是能够解决在图层中小地方显示大图片的解决方法。但是如果你的图层包含子图层那它就不是一个非常好的解决方案,因为,这样做的话每次你想『滑动』可视区域的时候,你就需要手工重新计算并更新所有的子图层位置。

这个时候就需要CAScrollLayer了。CAScrollLayer有一个-scrollToPoint:方法,它自动适应bounds的原点以便图层内容出现在滑动的地方。注意,这就是它做的所有事情。前面提到过,Core Animation并不处理用户输入,所以CAScrollLayer并不负责将触摸事件转换为滑动事件,既不渲染滚动条,也不实现任何iOS指定行为例如滑动反弹(当视图滑动超多了它的边界的将会反弹回正确的地方)。

让我们来用CAScrollLayer来常见一个基本的UIScrollView替代品。我们将会用CAScrollLayer作为视图的宿主图层,并创建一个自定义的UIView,然后用UIPanGestureRecognizer实现触摸事件响应。这段代码见清单6.10. 图6.11是运行效果:ScrollView显示了一个大于它的frameUIImageView

清单6.10 用CAScrollLayer实现滑动视图

#import "ScrollView.h"
#import @implementation ScrollView
+ (Class)layerClass
{
return [CAScrollLayer class];
}

- (void)setUp
{
//enable clipping
self.layer.masksToBounds = YES;

//attach pan gesture recognizer
UIPanGestureRecognizer *recognizer = nil;
recognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
[self addGestureRecognizer:recognizer];
}

- (id)initWithFrame:(CGRect)frame
{
//this is called when view is created in code
if ((self = [super initWithFrame:frame])) {
[self setUp];
}
return self;
}

- (void)awakeFromNib {
//this is called when view is created from a nib
[self setUp];
}

- (void)pan:(UIPanGestureRecognizer *)recognizer
{
//get the offset by subtracting the pan gesture
//translation from the current bounds origin
CGPoint offset = self.bounds.origin;
offset.x -= [recognizer translationInView:self].x;
offset.y -= [recognizer translationInView:self].y;

//scroll the layer
[(CAScrollLayer *)self.layer scrollToPoint:offset];

//reset the pan gesture translation
[recognizer setTranslation:CGPointZero inView:self];
}
@end

图6.11 用UIScrollView创建一个凑合的滑动视图

不同于UIScrollView,我们定制的滑动视图类并没有实现任何形式的边界检查(bounds checking)。图层内容极有可能滑出视图的边界并无限滑下去。CAScrollLayer并没有等同于UIScrollViewcontentSize的属性,所以当CAScrollLayer滑动的时候完全没有一个全局的可滑动区域的概念,也无法自适应它的边界原点至你指定的值。它之所以不能自适应边界大小是因为它不需要,内容完全可以超过边界。

那你一定会奇怪用CAScrollLayer的意义到底何在,因为你可以简单地用一个普通的CALayer然后手动适应边界原点啊。真相其实并不复杂,UIScrollView并没有用CAScrollLayer,事实上,就是简单的通过直接操作图层边界来实现滑动。

CAScrollLayer有一个潜在的有用特性。如果你查看CAScrollLayer的头文件,你就会注意到有一个扩展分类实现了一些方法和属性:

- (void)scrollPoint:(CGPoint)p;
- (void)scrollRectToVisible:(CGRect)r;
@property(readonly) CGRect visibleRect;

看到这些方法和属性名,你也许会以为这些方法给每个CALayer实例增加了滑动功能。但是事实上他们只是放置在CAScrollLayer中的图层的实用方法。scrollPoint:方法从图层树中查找并找到第一个可用的CAScrollLayer,然后滑动它使得指定点成为可视的。scrollRectToVisible:方法实现了同样的事情只不过是作用在一个矩形上的。visibleRect属性决定图层(如果存在的话)的哪部分是当前的可视区域。如果你自己实现这些方法就会相对容易明白一点,但是CAScrollLayer帮你省了这些麻烦,所以当涉及到实现图层滑动的时候就可以用上了。

6.7 CATiledLayer

有些时候你可能需要绘制一个很大的图片,常见的例子就是一个高像素的照片或者是地球表面的详细地图。iOS应用通畅运行在内存受限的设备上,所以读取整个图片到内存中是不明智的。载入大图可能会相当地慢,那些对你看上去比较方便的做法(在主线程调用UIImage-imageNamed:方法或者-imageWithContentsOfFile:方法)将会阻塞你的用户界面,至少会引起动画卡顿现象。

能高效绘制在iOS上的图片也有一个大小限制。所有显示在屏幕上的图片最终都会被转化为OpenGL纹理,同时OpenGL有一个最大的纹理尺寸(通常是2048*2048,或4096*4096,这个取决于设备型号)。如果你想在单个纹理中显示一个比这大的图,即便图片已经存在于内存中了,你仍然会遇到很大的性能问题,因为Core Animation强制用CPU处理图片而不是更快的GPU(见第12章『速度的曲调』,和第13章『高效绘图』,它更加详细地解释了软件绘制和硬件绘制)。

CATiledLayer为载入大图造成的性能问题提供了一个解决方案:将大图分解成小片然后将他们单独按需载入。让我们用实验来证明一下。

小片裁剪

这个示例中,我们将会从一个2048*2048分辨率的雪人图片入手。为了能够从CATiledLayer中获益,我们需要把这个图片裁切成许多小一些的图片。你可以通过代码来完成这件事情,但是如果你在运行时读入整个图片并裁切,那CATiledLayer这些所有的性能优点就损失殆尽了。理想情况下来说,最好能够逐个步骤来实现。

清单6.11 演示了一个简单的Mac OS命令行程序,它用CATiledLayer将一个图片裁剪成小图并存储到不同的文件中。

清单6.11 裁剪图片成小图的终端程序

#import 

int main(int argc, const char * argv[])
{
@autoreleasepool{
//handle incorrect arguments
if (argc < 2) {
NSLog(@"TileCutter arguments: inputfile");
return 0;
}

//input file
NSString *inputFile = [NSString stringWithCString:argv[1] encoding:NSUTF8StringEncoding];

//tile size
CGFloat tileSize = 256; //output path
NSString *outputPath = [inputFile stringByDeletingPathExtension];

//load image
NSImage *image = [[NSImage alloc] initWithContentsOfFile:inputFile];
NSSize size = [image size];
NSArray *representations = [image representations];
if ([representations count]){
NSBitmapImageRep *representation = representations[0];
size.width = [representation pixelsWide];
size.height = [representation pixelsHigh];
}
NSRect rect = NSMakeRect(0.0, 0.0, size.width, size.height);
CGImageRef imageRef = [image CGImageForProposedRect:&rect context:NULL hints:nil];

//calculate rows and columns
NSInteger rows = ceil(size.height / tileSize);
NSInteger cols = ceil(size.width / tileSize);

//generate tiles
for (int y = 0; y < rows; ++y) {
for (int x = 0; x < cols; ++x) {
//extract tile image
CGRect tileRect = CGRectMake(x*tileSize, y*tileSize, tileSize, tileSize);
CGImageRef tileImage = CGImageCreateWithImageInRect(imageRef, tileRect);

//convert to jpeg data
NSBitmapImageRep *imageRep = [[NSBitmapImageRep alloc] initWithCGImage:tileImage];
NSData *data = [imageRep representationUsingType: NSJPEGFileType properties:nil];
CGImageRelease(tileImage);

//save file
NSString *path = [outputPath stringByAppendingFormat: @"_i_i.jpg", x, y];
[data writeToFile:path atomically:NO];
}
}
}
return 0;
}

这个程序将2048*2048分辨率的雪人图案裁剪成了64个不同的256*256的小图。(256*256是CATiledLayer的默认小图大小,默认大小可以通过tileSize属性更改)。程序接受一个图片路径作为命令行的第一个参数。我们可以在编译的scheme将路径参数硬编码然后就可以在Xcode中运行了,但是以后作用在另一个图片上就不方便了。所以,我们编译了这个程序并把它保存到敏感的地方,然后从终端调用,如下面所示:

> path/to/TileCutterApp path/to/Snowman.jpg

The app is very basic, but could easily be extended to support additional arguments such as tile size, or to export images in formats other than JPEG. The result of running it is a sequence of 64 new images, named as follows:

这个程序相当基础,但是能够轻易地扩展支持额外的参数比如小图大小,或者导出格式等等。运行结果是64个新图的序列,如下面命名:

Snowman_00_00.jpg
Snowman_00_01.jpg
Snowman_00_02.jpg
...
Snowman_07_07.jpg

既然我们有了裁切后的小图,我们就要让iOS程序用到他们。CATiledLayer很好地和UIScrollView集成在一起。除了设置图层和滑动视图边界以适配整个图片大小,我们真正要做的就是实现-drawLayer:inContext:方法,当需要载入新的小图时,CATiledLayer就会调用到这个方法。

清单6.12演示了代码。图6.12是代码运行结果。

清单6.12 一个简单的滚动CATiledLayer实现

#import "ViewController.h"
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIScrollView *scrollView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//add the tiled layer
CATiledLayer *tileLayer = [CATiledLayer layer];
tileLayer.frame = CGRectMake(0, 0, 2048, 2048);
tileLayer.delegate = self; [self.scrollView.layer addSublayer:tileLayer];

//configure the scroll view
self.scrollView.contentSize = tileLayer.frame.size;

//draw layer
[tileLayer setNeedsDisplay];
}

- (void)drawLayer:(CATiledLayer *)layer inContext:(CGContextRef)ctx
{
//determine tile coordinate
CGRect bounds = CGContextGetClipBoundingBox(ctx);
NSInteger x = floor(bounds.origin.x / layer.tileSize.width);
NSInteger y = floor(bounds.origin.y / layer.tileSize.height);

//load tile image
NSString *imageName = [NSString stringWithFormat: @"Snowman_i_i", x, y];
NSString *imagePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"jpg"];
UIImage *tileImage = [UIImage imageWithContentsOfFile:imagePath];

//draw tile
UIGraphicsPushContext(ctx);
[tileImage drawInRect:bounds];
UIGraphicsPopContext();
}
@end

图6.12

图6.12 用UIScrollView滚动CATiledLayer

当你滑动这个图片,你会发现当CATiledLayer载入小图的时候,他们会淡入到界面中。这是CATiledLayer的默认行为。(你可能已经在iOS 6之前的苹果地图程序中见过这个效果)你可以用fadeDuration属性改变淡入时长或直接禁用掉。CATiledLayer(不同于大部分的UIKit和Core Animation方法)支持多线程绘制,-drawLayer:inContext:方法可以在多个线程中同时地并发调用,所以请小心谨慎地确保你在这个方法中实现的绘制代码是线程安全的。

Retina小图

你也许已经注意到了这些小图并不是以Retina的分辨率显示的。为了以屏幕的原生分辨率来渲染CATiledLayer,我们需要设置图层的contentsScale来匹配UIScreenscale属性:

tileLayer.contentsScale = [UIScreen mainScreen].scale;

有趣的是,tileSize是以像素为单位,而不是点,所以增大了contentsScale就自动有了默认的小图尺寸(现在它是128*128的点而不是256*256).所以,我们不需要手工更新小图的尺寸或是在Retina分辨率下指定一个不同的小图。我们需要做的是适应小图渲染代码以对应安排scale的变化,然而:

//determine tile coordinate
CGRect bounds = CGContextGetClipBoundingBox(ctx);
CGFloat scale = [UIScreen mainScreen].scale;
NSInteger x = floor(bounds.origin.x / layer.tileSize.width * scale);
NSInteger y = floor(bounds.origin.y / layer.tileSize.height * scale);

通过这个方法纠正scale也意味着我们的雪人图将以一半的大小渲染在Retina设备上(总尺寸是1024*1024,而不是2048*2048)。这个通常都不会影响到用CATiledLayer正常显示的图片类型(比如照片和地图,他们在设计上就是要支持放大缩小,能够在不同的缩放条件下显示),但是也需要在心里明白。

收起阅读 »

Android修炼系列,图解抓包和弱网测试

本节主要介绍下,如何使用 Charles 进行抓包和模拟弱网环境测试。Charles 能够帮助我们查看设备和 Internet 之间的所有 HTTP 和 SSL/HTTPS 通信,这包括请求、响应和 HTTP 头。 HTTP代理 我们要保证手机设备和电脑在...
继续阅读 »

本节主要介绍下,如何使用 Charles 进行抓包和模拟弱网环境测试。Charles 能够帮助我们查看设备和 Internet 之间的所有 HTTP 和 SSL/HTTPS 通信,这包括请求、响应和 HTTP 头。


HTTP代理


我们要保证手机设备和电脑在一个局域网下,这点要注意。


安装完 Charles 软件后,首先要进行 HTTP 代理设置,默认端口号:8888


抓包1.png


接着查看电脑 IP,将手机 WIFI 网络选项也进行代理设置。通常步骤为 WIFI 高级选项 -> 代理手动 -> 输入IP与端口 -> 保存。如本例中将安卓设备的 WIFI 代理设置为 192.168.0.110


抓包2.jpg


随后会有连接成功的提示,点击允许。


抓包3.jpg


Allow 之后,我们就进入抓包界面了。请求信息会在界面的左侧展示。但是通过下图也能发现,https 的请求抓包乱码。


抓包4.png


针对HTTPS乱码的问题,我们需要设置 HTTPS 代理。


HTTPS 代理


要抓取 https 的接口的请求信息,那么 Charles 需要在电脑端安装证书。


抓包5.png


在电脑如下目录下,我们双击安装证书,并信任。


抓包6.png


证书安装完毕,Charles 还需要进行 SSL 代理配置。


抓包7.png


其中 Charles 的 Location 配置是支持通配符的,如不需要抓取特定域名,我们可直接填写 * 。Host的配置,ssl port 常规为 443。


抓包8.png


配置好SSL代理之后,我们同样需要给待测试手机安装证书,下证书载地址可通过如下方式查看。


抓包9.png


通过下图,我们知下载地址:chls.pro/ssl 我们打开手机浏览器,输入该地址下载手机证书。随后安装,并信任


抓包10.png


当我们操作完毕之后,我们就能抓取部分 HTTPS 的请求了。我实际测试有些 HTTPS 请求还是没办法脱码的。


网速配置


弱网环境测试就简单多了,在说之前,我们先来看下 Charles 工具栏中提供的快捷按钮:


抓包11.jpg



  • 清除捕获到的所有请求


  • 红点状态说明正在捕获请求,灰色状态说明目前没有捕获请求。


  • 停止SSL代理


  • 灰色状态说明是没有开启网速节流,绿色状态说明开启了网速节流。


  • 灰色状态说明是没有开启断点,红色状态说明开启了断点。


  • 编辑修改请求,点击之后可以修改请求的内容。


  • 重复发送请求,点击之后选中的请求会被再次发送。


  • 验证选中的请求的响应。


  • 常用功能,包含了 Tools 菜单中的常用功能。


  • 常用设置,包含了 Proxy 菜单中的常用设置。



这里的小乌龟图标就是我们所需要的啦。


当然我们也可通过 Throttle Setting 来进行节流控制。其包括 Bandwidth:带宽、Utilistation:利用百分比、Round-trip:往返延迟、MTU:字节。这里选择 BandWidth(带宽)复选框来开启限速。


抓包12.png



好了,本文到此就结束了。知识无涯,勿焦虑。



收起阅读 »

iOS 专用图层 四

6.5 CAReplicatorLayerCAReplicatorLayer的目的是为了高效生成许多相似的图层。它会绘制一个或多个图层的子图层,并在每个复制体上应用不同的变换。看上去演示能够更加解释这些,我们来写个例子吧。重复图层(Repeating Laye...
继续阅读 »

6.5 CAReplicatorLayer

CAReplicatorLayer的目的是为了高效生成许多相似的图层。它会绘制一个或多个图层的子图层,并在每个复制体上应用不同的变换。看上去演示能够更加解释这些,我们来写个例子吧。

重复图层(Repeating Layers)

清单6.8中,我们在屏幕的中间创建了一个小白色方块图层,然后用CAReplicatorLayer生成十个图层组成一个圆圈。instanceCount属性指定了图层需要重复多少次。instanceTransform指定了一个CATransform3D3D变换(这种情况下,下一图层的位移和旋转将会移动到圆圈的下一个点)。

变换是逐步增加的,每个实例都是相对于前一实例布局。这就是为什么这些复制体最终不会出现在同意位置上,图6.8是代码运行结果。

清单6.8 用CAReplicatorLayer重复图层

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@end

@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//create a replicator layer and add it to our view
CAReplicatorLayer *replicator = [CAReplicatorLayer layer];
replicator.frame = self.containerView.bounds;
[self.containerView.layer addSublayer:replicator];

//configure the replicator
replicator.instanceCount = 10;

//apply a transform for each instance
CATransform3D transform = CATransform3DIdentity;
transform = CATransform3DTranslate(transform, 0, 200, 0);
transform = CATransform3DRotate(transform, M_PI / 5.0, 0, 0, 1);
transform = CATransform3DTranslate(transform, 0, -200, 0);
replicator.instanceTransform = transform;

//apply a color shift for each instance
replicator.instanceBlueOffset = -0.1;
replicator.instanceGreenOffset = -0.1;

//create a sublayer and place it inside the replicator
CALayer *layer = [CALayer layer];
layer.frame = CGRectMake(100.0f, 100.0f, 100.0f, 100.0f);
layer.backgroundColor = [UIColor whiteColor].CGColor;
[replicator addSublayer:layer];
}
@end

图6.8

图6.8 用CAReplicatorLayer创建一圈图层

注意到当图层在重复的时候,他们的颜色也在变化:这是用instanceBlueOffsetinstanceGreenOffset属性实现的。通过逐步减少蓝色和绿色通道,我们逐渐将图层颜色转换成了红色。这个复制效果看起来很酷,但是CAReplicatorLayer真正应用到实际程序上的场景比如:一个游戏中导弹的轨迹云,或者粒子爆炸(尽管iOS 5已经引入了CAEmitterLayer,它更适合创建任意的粒子效果)。除此之外,还有一个实际应用是:反射。

反射

使用CAReplicatorLayer并应用一个负比例变换于一个复制图层,你就可以创建指定视图(或整个视图层次)内容的镜像图片,这样就创建了一个实时的『反射』效果。让我们来尝试实现这个创意:指定一个继承于UIViewReflectionView,它会自动产生内容的反射效果。实现这个效果的代码很简单(见清单6.9),实际上用ReflectionView实现这个效果会更简单,我们只需要把ReflectionView的实例放置于Interface Builder(见图6.9),它就会实时生成子视图的反射,而不需要别的代码(见图6.10).

清单6.9 用CAReplicatorLayer自动绘制反射

#import "ReflectionView.h"
#import

@implementation ReflectionView

+ (Class)layerClass
{
return [CAReplicatorLayer class];
}

- (void)setUp
{
//configure replicator
CAReplicatorLayer *layer = (CAReplicatorLayer *)self.layer;
layer.instanceCount = 2;

//move reflection instance below original and flip vertically
CATransform3D transform = CATransform3DIdentity;
CGFloat verticalOffset = self.bounds.size.height + 2;
transform = CATransform3DTranslate(transform, 0, verticalOffset, 0);
transform = CATransform3DScale(transform, 1, -1, 0);
layer.instanceTransform = transform;

//reduce alpha of reflection layer
layer.instanceAlphaOffset = -0.6;
}

- (id)initWithFrame:(CGRect)frame
{
//this is called when view is created in code
if ((self = [super initWithFrame:frame])) {
[self setUp];
}
return self;
}

- (void)awakeFromNib
{
//this is called when view is created from a nib
[self setUp];
}
@end

图6.9

图6.9 在Interface Builder中使用ReflectionView

图6.10

图6.10 ReflectionView自动实时产生反射效果。

开源代码ReflectionView完成了一个自适应的渐变淡出效果(用CAGradientLayer和图层蒙板实现),代码见 https://github.com/nicklockwood/ReflectionView

收起阅读 »

iOS 专用图层 三

6.3 CATransformLayer当我们在构造复杂的3D事物的时候,如果能够组织独立元素就太方便了。比如说,你想创造一个孩子的手臂:你就需要确定哪一部分是孩子的手腕,哪一部分是孩子的前臂,哪一部分是孩子的肘,哪一部分是孩子的上臂,哪一部分是孩子的肩膀等等...
继续阅读 »

6.3 CATransformLayer

当我们在构造复杂的3D事物的时候,如果能够组织独立元素就太方便了。比如说,你想创造一个孩子的手臂:你就需要确定哪一部分是孩子的手腕,哪一部分是孩子的前臂,哪一部分是孩子的肘,哪一部分是孩子的上臂,哪一部分是孩子的肩膀等等。

当然是允许独立地移动每个区域的啦。以肘为指点会移动前臂和手,而不是肩膀。Core Animation图层很容易就可以让你在2D环境下做出这样的层级体系下的变换,但是3D情况下就不太可能,因为所有的图层都把他的孩子都平面化到一个场景中(第五章『变换』有提到)。

CATransformLayer解决了这个问题,CATransformLayer不同于普通的CALayer,因为它不能显示它自己的内容。只有当存在了一个能作用域子图层的变换它才真正存在。CATransformLayer并不平面化它的子图层,所以它能够用于构造一个层级的3D结构,比如我的手臂示例。

用代码创建一个手臂需要相当多的代码,所以我就演示得更简单一些吧:在第五章的立方体示例,我们将通过旋转camara来解决图层平面化问题而不是像立方体示例代码中用的sublayerTransform。这是一个非常不错的技巧,但是只能作用域单个对象上,如果你的场景包含两个立方体,那我们就不能用这个技巧单独旋转他们了。

那么,就让我们来试一试CATransformLayer吧,第一个问题就来了:在第五章,我们是用多个视图来构造了我们的立方体,而不是单独的图层。我们不能在不打乱已有的视图层次的前提下在一个本身不是有寄宿图的图层中放置一个寄宿图图层。我们可以创建一个新的UIView子类寄宿在CATransformLayer(用+layerClass方法)之上。但是,为了简化案例,我们仅仅重建了一个单独的图层,而不是使用视图。这意味着我们不能像第五章一样在立方体表面显示按钮和标签,不过我们现在也用不到这个特性。

清单6.5就是代码。我们以我们在第五章使用过的相同基本逻辑放置立方体。但是并不像以前那样直接将立方面添加到容器视图的宿主图层,我们将他们放置到一个CATransformLayer中创建一个独立的立方体对象,然后将两个这样的立方体放进容器中。我们随机地给立方面染色以将他们区分开来,这样就不用靠标签或是光亮来区分他们。图6.5是运行结果。

清单6.5 用CATransformLayer装配一个3D图层体系

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@end

@implementation ViewController

- (CALayer *)faceWithTransform:(CATransform3D)transform
{
//create cube face layer
CALayer *face = [CALayer layer];
face.frame = CGRectMake(-50, -50, 100, 100);

//apply a random color
CGFloat red = (rand() / (double)INT_MAX);
CGFloat green = (rand() / (double)INT_MAX);
CGFloat blue = (rand() / (double)INT_MAX);
face.backgroundColor = [UIColor colorWithRed:red green:green blue:blue alpha:1.0].CGColor;

//apply the transform and return
face.transform = transform;
return face;
}

- (CALayer *)cubeWithTransform:(CATransform3D)transform
{
//create cube layer
CATransformLayer *cube = [CATransformLayer layer];

//add cube face 1
CATransform3D ct = CATransform3DMakeTranslation(0, 0, 50);
[cube addSublayer:[self faceWithTransform:ct]];

//add cube face 2
ct = CATransform3DMakeTranslation(50, 0, 0);
ct = CATransform3DRotate(ct, M_PI_2, 0, 1, 0);
[cube addSublayer:[self faceWithTransform:ct]];

//add cube face 3
ct = CATransform3DMakeTranslation(0, -50, 0);
ct = CATransform3DRotate(ct, M_PI_2, 1, 0, 0);
[cube addSublayer:[self faceWithTransform:ct]];

//add cube face 4
ct = CATransform3DMakeTranslation(0, 50, 0);
ct = CATransform3DRotate(ct, -M_PI_2, 1, 0, 0);
[cube addSublayer:[self faceWithTransform:ct]];

//add cube face 5
ct = CATransform3DMakeTranslation(-50, 0, 0);
ct = CATransform3DRotate(ct, -M_PI_2, 0, 1, 0);
[cube addSublayer:[self faceWithTransform:ct]];

//add cube face 6
ct = CATransform3DMakeTranslation(0, 0, -50);
ct = CATransform3DRotate(ct, M_PI, 0, 1, 0);
[cube addSublayer:[self faceWithTransform:ct]];

//center the cube layer within the container
CGSize containerSize = self.containerView.bounds.size;
cube.position = CGPointMake(containerSize.width / 2.0, containerSize.height / 2.0);

//apply the transform and return
cube.transform = transform;
return cube;
}

- (void)viewDidLoad
{
[super viewDidLoad];

//set up the perspective transform
CATransform3D pt = CATransform3DIdentity;
pt.m34 = -1.0 / 500.0;
self.containerView.layer.sublayerTransform = pt;

//set up the transform for cube 1 and add it
CATransform3D c1t = CATransform3DIdentity;
c1t = CATransform3DTranslate(c1t, -100, 0, 0);
CALayer *cube1 = [self cubeWithTransform:c1t];
[self.containerView.layer addSublayer:cube1];

//set up the transform for cube 2 and add it
CATransform3D c2t = CATransform3DIdentity;
c2t = CATransform3DTranslate(c2t, 100, 0, 0);
c2t = CATransform3DRotate(c2t, -M_PI_4, 1, 0, 0);
c2t = CATransform3DRotate(c2t, -M_PI_4, 0, 1, 0);
CALayer *cube2 = [self cubeWithTransform:c2t];
[self.containerView.layer addSublayer:cube2];
}
@end

图6.5

图6.5 同一视角下的俩不同变换的立方体

6.4 CAGradientLayer

CAGradientLayer是用来生成两种或更多颜色平滑渐变的。用Core Graphics复制一个CAGradientLayer并将内容绘制到一个普通图层的寄宿图也是有可能的,但是CAGradientLayer的真正好处在于绘制使用了硬件加速。

基础渐变

我们将从一个简单的红变蓝的对角线渐变开始(见清单6.6).这些渐变色彩放在一个数组中,并赋给colors属性。这个数组成员接受CGColorRef类型的值(并不是从NSObject派生而来),所以我们要用通过bridge转换以确保编译正常。

CAGradientLayer也有startPointendPoint属性,他们决定了渐变的方向。这两个参数是以单位坐标系进行的定义,所以左上角坐标是{0, 0},右下角坐标是{1, 1}。代码运行结果如图6.6

清单6.6 简单的两种颜色的对角线渐变

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//create gradient layer and add it to our container view
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = self.containerView.bounds;
[self.containerView.layer addSublayer:gradientLayer];

//set gradient colors
gradientLayer.colors = @[(__bridge id)[UIColor redColor].CGColor, (__bridge id)[UIColor blueColor].CGColor];

//set gradient start and end points
gradientLayer.startPoint = CGPointMake(0, 0);
gradientLayer.endPoint = CGPointMake(1, 1);
}
@end

图6.6

图6.6 用CAGradientLayer实现简单的两种颜色的对角线渐变

多重渐变

如果你愿意,colors属性可以包含很多颜色,所以创建一个彩虹一样的多重渐变也是很简单的。默认情况下,这些颜色在空间上均匀地被渲染,但是我们可以用locations属性来调整空间。locations属性是一个浮点数值的数组(以NSNumber包装)。这些浮点数定义了colors属性中每个不同颜色的位置,同样的,也是以单位坐标系进行标定。0.0代表着渐变的开始,1.0代表着结束。

locations数组并不是强制要求的,但是如果你给它赋值了就一定要确保locations的数组大小和colors数组大小一定要相同,否则你将会得到一个空白的渐变。

清单6.7展示了一个基于清单6.6的对角线渐变的代码改造。现在变成了从红到黄最后到绿色的渐变。locations数组指定了0.0,0.25和0.5三个数值,这样这三个渐变就有点像挤在了左上角。(如图6.7).

清单6.7 在渐变上使用locations

- (void)viewDidLoad {
[super viewDidLoad];

//create gradient layer and add it to our container view
CAGradientLayer *gradientLayer = [CAGradientLayer layer];
gradientLayer.frame = self.containerView.bounds;
[self.containerView.layer addSublayer:gradientLayer];

//set gradient colors
gradientLayer.colors = @[(__bridge id)[UIColor redColor].CGColor, (__bridge id) [UIColor yellowColor].CGColor, (__bridge id)[UIColor greenColor].CGColor];

//set locations
gradientLayer.locations = @[@0.0, @0.25, @0.5];

//set gradient start and end points
gradientLayer.startPoint = CGPointMake(0, 0);
gradientLayer.endPoint = CGPointMake(1, 1);
}

图6.7

图6.7 用locations构造偏移至左上角的三色渐变

收起阅读 »

字节跳动开源AndroidPLThook方案bhook

字节 bhook 开源 github.com/bytedance/b… 字节的 Android PLT hook 方案 bhook 开源了。bhook 支持 Android 4.1 - 12 (API level 16 - 31),支持 armeabi-v...
继续阅读 »

字节 bhook 开源


github.com/bytedance/b…


字节的 Android PLT hook 方案 bhook 开源了。bhook 支持 Android 4.1 - 12 (API level 16 - 31),支持 armeabi-v7a, arm64-v8a, x86 和 x86_64,使用 MIT 许可证授权。


字节的大多数 Android app 都在线上使用了 bhook 作为 PLT hook 方案。字节内部有 20 多个不同技术纬度的 SDK 使用了 bhook。bhook 在线上稳定性,功能性,性能等多个方面都达到了预期。


Android native hook


随着 Android app 开发的技术栈不断向 native 层扩展,native hook 已经被用于越来越多的技术场景中。Android native hook 的实现方式有很多种,其中使用最广泛,并且通用性最强的是 inline hook 和 PLT hook。


inline hook 的功能无疑是最强大的,它受到的限制很少,几乎可以 hook 任何地方。inline hook 在线下场景中使用的比较多,业内现有的通用的 inline hook 开源方案或多或少都存在一些稳定性问题,而且基本都缺乏大规模的线上验证。


PLT hook 的优点是稳定性可控,可以真正的在线上全量使用。但 PLT hook 只能 hook 通过 PLT 表跳转的函数调用,这在一定程度上限制了它的使用场景。


在真实的线上环境中,经常是 PLT hook 和 inline hook 并存的,这样它们可以各自扬长避短,在不同的场景中发挥作用。


ELF


要弄清 Android PLT hook 的原理,需要了解 ELF 文件格式,以及 linker(动态连接器)加载 ELF 文件的过程。


app_process 和 so 库(动态链接库)都是 ELF(Executable and Linkable Format)格式的文件。对于运行时 native hook 来说,我们主要关心最终的产物,即 ELF 文件。


ELF 文件的起始处,有一个固定格式的定长的文件头。ELF 文件头中包含了 SHT(section header table)和 PHT(program header table)在当前 ELF 文件中的起始位置和长度。SHT 和 PHT 分别描述了 ELF 的“连接视图”和“执行视图”的基本信息。



Execution View(执行视图)


ELF 分为连接视图(Linking View)和执行视图(Execution View)。



  • 连接视图:ELF 未被加载到内存执行前,以 section 为单位的数据组织形式。

  • 执行视图:ELF 被加载到内存后,以 segment 为单位的数据组织形式。


PLT hook 并不是修改磁盘上的 ELF 文件,而是在运行时修改内存中的数据,因此我们主要关心的是执行视图,即 ELF 被加载到内存后,ELF 中的数据是如何组织和存放的。


linker 依据 ELF 文件执行视图中的信息,用 mmap 将 ELF 加载到内存中,执行 relocation(重定位)把外部引用的绝对地址填入 GOT 表和 DATA 中,然后设置内存页的权限,最后调用 init_array 中的各个初始化函数。


PLT hook 执行的时机是在 linker 完全加载完 ELF 之后,我们需要解析内存中的 ELF 数据,然后修改 relocation 的结果。


ELF 中可以包含很多类型的 section(节),下面介绍一些比较重要的,以及和 PLT hook 相关的 section。


Dynamic section


.dynamic 是专门为 linker 设计的,其中包含了 linker 解析和加载 ELF 时会用到的各项数据的索引。linker 在解析完 ELF 头和执行视图的内容后,就会开始解析 .dynamic


Data(数据)



  • .bss:未初始化的数据。比如:没有赋初值的全局变量和静态变量。(.bss 不占用 ELF 文件体积)

  • .data:已初始化的非只读数据。比如:int g_value = 1;,或者 size_t (*strlen_ptr)(const char *) = strlen;(初始化过程需要 linker relocation 参与才能知道外部 strlen 函数的绝对地址)

  • .rodata:已初始化的只读数据,加载完成后所属内存页会被 linker 设置为只读。比如:const int g_value = 1;

  • .data.rel.ro:已初始化的只读数据,初始化过程需要 linker relocation 参与,加载完成后所属内存页会被 linker 设置为只读。比如:const size_t (*strlen)(const char *) = strlen;


Code(代码)



  • .text:大多数函数被编译成二进制机器指令后,会存放在这里。

  • .init_array:有时候我们需要在 ELF 被加载后立刻自动执行一些逻辑,比如定义一个全局的 C++ 类的实例,这时候就需要在 .init_array 中调用这个类的构造函数。另外,也可以用 __attribute__((constructor)) 定义单独的 init 函数。

  • .plt:对外部或内部的符号的调用跳板,.plt 会从 .got.data.data.rel.ro 中查询符号的绝对地址,然后执行跳转。


Symbol(符号)


符号可以分为两类:“动态链接符号”和“内部符号(调试符号)”,这两个符号集合并不存在严格的相互包含关系,调试器一般会同时加载这两种符号。linker 只关心动态链接符号,内部符号并不会被 linker 加载到内存中。执行 PLT hook 时也只关心动态链接符号。



  • .dynstr:动态链接符号的字符串池,保存了动态链接过程中用到的所有字符串信息,比如:函数名,全局变量名。

  • .dynsym:动态链接符号的索引信息表,起到“关联”和“描述”的作用。


动态链接符号分为“导入符号”和“导出符号”:



  • 导出符号:指当前 ELF 提供给外部使用的符号。比如:libc.so 中的 open 就是 libc.so 的导出符号。

  • 导入符号:指当前 ELF 需要使用的外部符号。比如:你自己的 libtest.so 如果用到了 open,那么 open 就会被定义为 libtest.so 的导入符号。


顺便提一下,内部符号的信息包含在 .symtab.strtab.gnu_debugdata 中。


hash table(哈希表)


为了加速“动态链接符号的字符串”的查找过程,ELF 中包含了这些字符串的哈希表,通过查哈希表,可以快速确认 ELF 中是否存在某个动态链接符号,以及这个符号对应的信息项在 .dynsym 中的偏移位置。


历史原因,Android ELF 中会存在两种格式的哈希表:



  • .hash:SYSV hash。其中包含了所有的动态链接符号。

  • .gnu.hash:GNU hash。只包含动态链接符号中的导出符号。


ELF 中可能同时包含 .hash.gnu.hash,也可能只包含其中一个。具体看 ELF 编译时的静态链接参数 -Wl,--hash-style,可以设置为 sysvgnuboth。从 Android 6.0 开始,linker 支持了 .gnu.hash 的解析。


linker(动态链接器)



linker 在加载 ELF 时的最主要工作是 relocation(重定位),这个过程的目的是为当前 ELF 的每个“导入符号”找到对应的外部符号(函数或数据)的绝对地址。最终,这些地址会被写入以下几个地方:



  • .got.plt:保存外部函数的绝对地址。这就是我们经常会听到的 “GOT 表”。

  • .data.data.rel.ro:保存外部数据(包括函数指针)的绝对地址。


要完成 relocation 过程,需要依赖于 ELF 中的以下信息:



  • .rel.plt.rela.plt:用于关联 .dynsym.got.plt。这就是我们经常会听到的 “PLT 表”。

  • .rel.dyn.rela.dyn.rel.dyn.aps2.rela.dyn.aps2:用于关联 .dynsym.data.data.rel.ro


Android 只在 64 位实现中使用 RELA 格式,它比 REL 格式多了附加的 r_addend 字段。另外,Android 从 6.0 开始支持 aps2 格式的 .rel.dyn.rela.dyn 数据,这是一种 sleb128 编码格式的数据,读取时需要特别的解码逻辑。


relocation 完成之后的函数调用关系如下:



relocation 完成之后的数据引用关系如下:



Android PLT hook


PLT hook 基本原理


了解了 ELF 格式和 linker 的 relocation 过程之后,PLT hook 的过程就不言自明了。它做了和 relocation 类似的事情。即:通过符号名,先在 hash table 中找到对应的符号信息(在 .dynsym 中),再找到对应的 PLT 信息(在 .rel.plt.rela.plt.rel.dyn.rela.dyn.rel.dyn.aps2.rela.dyn.aps2 中),最后找到绝对地址信息(在 .got.plt.data.data.rel.ro 中)。最后要做的就是修改这个绝对地址的值,改为我们需要的自己的“代理函数”的地址。


要注意的是,在修改这个绝对地址之前,需要先用 mprotect 设置当前地址位置所在内存页为“可写”的,因为 linker 在做完 relocation 后会把 .got.plt.data.rel.ro 设置为只读的。修改完之后,需要用 __builtin___clear_cache 来清除该内存位置的 CPU cache,以使修改能立刻生效。


xHook 的不足之处


xHook 是一个开源较早的 Android PLT hook 方案,受到了很多的关注。xHook 比较好的实现了 ELF 解析和绝对地址替换的工作。但是作为一个工程化的 PLT hook 方案,xHook 存在很多不足之处,主要有:



  • native 崩溃兜底机制有缺陷,导致线上崩溃无法完全避免。

  • 无法自动对新加载的 ELF 执行 hook。(需要外部反复调用 refresh 来“发现”新加载的 ELF。但是在什么时机调用 refresh 呢?频率太高会影响性能,频率太低会导致 hook 不及时)

  • 由于依赖于链式调用的机制。如果一个调用点被多次 hook,在对某个 proxy 函数执行 unhook 后,链中后续的 proxy 函数就会丢失。

  • 只使用了读 maps 的方式来遍历 ELF。在高版本 Android 系统和部分机型中兼容性不好,经常会发生 hook 不到的情况。

  • API 设计中使用了正则来指定 hook 哪些目标 ELF,运行效率不佳。

  • 需要在真正执行 hook 前,注册完所有的 hook 点,一旦开始执行 hook(调用 refresh 后),不能再添加 hook 点。这种设计是很不友好的。

  • 无法适配 Android 8.0 引入 Linker Namespace 机制(同一个函数符号,在进程中可能存在多个实现)。


由于存在上述这些稳定性、有效性、功能性上的问题,使 xHook 难以真正大规模的用于线上环境中。


更完善的 Android PLT hook 方案


我们迫切需要一个新的更完善的 Android PLT hook 方案,它应该是什么样子的呢?我认为它应该满足这些条件:



  • 要有一套真正可靠的 native 崩溃兜底机制,来避免可控范围内的 native 崩溃。

  • 可以随时 hook 和 unhook 单个、部分、全部的调用者 ELF。

  • 当新的 ELF 被加载到内存后,它应该自动的被执行所有预定的 hook 操作。

  • 多个使用方如果 hook 了同一个调用点,它们应该可以彼此独立的执行 unhook,相互不干扰。

  • 为了适配 Android linker namespace,应该可以指定 hook 函数的被调用者 ELF。

  • 能自动避免由于 hook 引起的意外的“递归调用”和“环形调用”。比如:open 的 proxy 函数中调用了 read,然后 read 的 proxy 函数中又调用了 open。如果这两个 proxy 存在于两个独立的 SDK 中,此时形成的环形调用将很难在 SDK 开发阶段被发现。如果在更多的 SDK 之间形成了一个更大的 proxy 函数调用环,情况将会失去控制。

  • proxy 函数中要能以正常的方式获取 backtrace(libunwind、libunwindstack、llvm libunwind、FP unwind 等)。有大量的业务场景是需要 hook 后在 proxy 函数中抓取和保存 backtrace,然后在特定的时机 dump 和聚合这些 backtrace,符号化后再将数据投递到服务端,从而监控和发现业务问题。

  • hook 管理机制本身带来的额外性能损耗要足够低。


我们带着上面的这些目标设计和开发了 bhook。


字节 bhook 介绍


ELF 和 linker 前面已经介绍过了,下面介绍 bhook 中另外几个关键模块。



DL monitor


在 Android 系统中,动态加载 so 库最终是通过 dlopenandroid_dlopen_ext 完成的,通过 dlclose 则可以卸载 so 库。


bhook 在内部 hook 了这三个函数调用。因此,当有新的 so 被加载到内存后,bhook 能立刻感知到,于是可以立刻对它执行预定的 hook 任务。当有 so 正在被卸载时,bhook 也能立刻感知到,并且会通过内部的读写锁机制与“ELF cache 和 hook 执行模块”同步,以此保证“正在被 hook 的 so 不会正在被卸载”。


Android 从 7.0 开始不再允许 app 中 dlopen 系统库;从 8.0 开始引入了 linker namespace 机制,并且 libdl.so 不再是 linker 的虚拟入口,而成为了一个真实的 so 文件。对于 linker 来说,Android 7.0 和 8.0 是两个重要的版本。


我们需要设法绕过系统对 app dlopen 系统库的限制,否则 hook dlopenandroid_dlopen_ext 之后,在代理函数中是无法直接调用原始的 dlopenandroid_dlopen_ext 函数的。


这里我们参考了 ByteDance Raphael(github.com/bytedance/m… Android 7.0 开始,hook dlopenandroid_dlopen_ext 后不再调用原函数,而是通过调用 linker 和 libdl.so 内部函数的方式绕过了限制。主要用到了以下几个符号对应的内部函数:


Android 7.x linker:


__dl__ZL10dlopen_extPKciPK17android_dlextinfoPv
__dl__Z9do_dlopenPKciPK17android_dlextinfoPv
__dl__Z23linker_get_error_bufferv
__dl__ZL23__bionic_format_dlerrorPKcS0_

Android 8.0+ libdl.so:


__loader_dlopen
__loader_android_dlopen_ext

trampoline


简单的 PLT hook 方案(比如 xHook)是不需要 trampoline 的,只需要替换 .got.plt(和 .data.data.rel.ro)中的绝对地址就可以了。但是这种方式会导致“同一个 hook 点的多个 proxy 函数形成链式调用”(类似于 Linux 通过 sigaction 注册的 signal handler),如果其中一个 proxy 被 unhook 了,那么“链” 中后续的 proxy 也会丢失。xHook 就存在这个问题:



当 proxy 1 被 unhook 后,proxy 2 也从调用链上消失了,因为 proxy 1 根本不知道 proxy 2 的存在,在 unhook proxy 1 时,会试图恢复最初的初始值,即 callee 的地址。


为了解决这个问题,对于每个被 hook 的函数调用点,我们都需要一个对应的管理入口函数,我们改为在 GOT 表中写入这个管理入口函数的地址。同时,对于每个被 hook 的函数调用点,我们还需要维护一个 proxy 函数列表,在管理入口函数中,需要遍历和调用 proxy 函数列表中的每一个具体 proxy 函数。


为了在运行时达到指定跳转的效果,我们需要用 mmapmprotect 来创建 shellcode。按照术语惯例,我们把这里创建的跳转逻辑称为 trampoline(蹦床):



另外,为了检测和避免“环形调用”,每次 trampoline 开始执行时,都会开始记录 proxy 函数的执行栈,在 proxy 函数链中遍历执行时,会检测当前待执行的 proxy 函数是否已经在执行栈中出现过,如果出现过,就说明发生了“环形调用”,此时会忽略 proxy 函数链中后续所有的 proxy 函数,直接执行最后的“原函数”。


trampoline 实现的难点在于性能。trampoline 给执行流程注入了额外的逻辑,在多线程环境中,proxy 调用链会被高频的遍历,其中保存的 proxy 函数可能随时会增加和减少,我们还需要保存 proxy 函数的执行栈。所有这些逻辑都不能加锁,否则 hook 高频函数时,性能损耗会比较明显。


native 崩溃兜底


执行 hook 操作时,需要直接计算很多的内存绝对地址,然后对这些内存位置进行读写,但这样做并不总是安全的,我们可能会遇到这些情况:



  • 在 DL monitor 初始化的过程中,对 dlclose 的 hook 尚未完成时,此时 linker 执行了 dlclose,恰恰 dlclose 了我们正在执行 dlclose hook 操作的 ELF。

  • ELF 文件可能意外损坏,导致 linker 加载了格式不正确的 ELF。


这时候,对指定内存位置的读写可能会发生 sigsegv 或 sigbus,导致 native 崩溃。我们需要一种类似 Java / C++ try-catch 的机制来保护这种危险的操作,避免发生崩溃:


int *p = NULL;

TRY(SIGSEGV, SIGBUS) {
*p = 1;
} CATCH() {
LOG("There was a problem, but it's okay.");
} EXIT

当崩溃发生时,因为我们明白在保护的代码区间中只有“内存读”或“单个内存写”操作,因此忽略这种崩溃并不会带来任何副作用。在 Java 虚拟机中,也有类似的机制用于检测 native 崩溃,并且创建合适的 Java 异常。


bhook 通过注册 sigsegv 和 sigbus 信号处理函数来进行 native 崩溃兜底,在 try 块开头用 sigsetjmp 保存寄存器和 sigmask,当发生崩溃时,在信号处理函数中用 siglongjmp 跳转到 catch 块中并恢复 sigmask。


值得注意的几个问题:



  • ART sigchain 代理了 sigactionsigprocmask 等函数,我们需要用 dlsym 在 libc.so 中找到原始的函数再调用它们。

  • bionic 和 ART sigchain 在某些 AOSP 版本上存在 bug,所以我们需要优先使用 sigaction64sigprocmask64,而不是 sigactionsigprocmask

  • 在正确的地方用正确的方式设置 sigmask 很重要。

  • 我们的 try-catch 机制运行于多线程环境中,所以需要以某种线程独立的方式来保存 sigjmp_buf

  • 考虑到性能和更多使用场景,整个机制需要无锁、无堆内存分配、无 TLS 操作、线程安全,异步信号安全。


bhook 的 native 崩溃兜底模块经过了比较严格的压力测试和线上测试,如果正确的使用,可以达到预期的效果。如你在 bhook 的源码中所见,我们故意把这个模块设计成现在的样子(只有一个 .c 和 一个 .h 文件,并且没有任何外部依赖),这样做的好处是容易移植和复用。如果你想把这个模块用在自己的工程中,请注意以下几点:



  • native 崩溃兜底属于“高危”操作,可能引起不确定的难以排查的问题。所以能不用尽量不要用。

  • 纯业务类型的 native 库请不要使用 native 崩溃兜底。而是应该让崩溃暴露出来,然后修复问题。

  • try 块中的逻辑越少越好。比如兜底 sigsegv 和 sigbus 时,最好 try 块中只有一些内存地址的读操作和单个写操作,尽量不要调用外部函数(包括 mallocfreenewdelete 等)。

  • try 块中尽量不要使用 C++。某些 C++ 的语法封装,编译器会为它生成一些意外的逻辑(比如读写 C++ TLS 变量,编译器会生成 _emutls_get_address 调用,其中可能会调用 malloc)。

  • 在当前的设计中:try 块中请不要调用 return,否则会跳过 catch 或 exit 块中的回收逻辑,引起难以排查的问题。另外,在 try 块中不可以嵌套使用另一个“相同信号的 try”。
收起阅读 »

RecyclerView 添加分割线,ItemDecoration 的实用技巧

官网解释: An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter'...
继续阅读 »

官网解释: An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.


我的理解:ItemDecoration 允许我们给 recyclerview 中的 item 添加专门的绘制和布局;比如分割线、强调和装饰等等。


默认 recyclerview 的表现像下面这样


image.png


其实我想要的是这样


image.png


如果我们不使用这个的话,那么我们在编写 xml 文件的时候只能添加 layout_margin 这样的值,而且即便这样在有些场景下也是不好用的。其实也没关系我们可以使用代码控制,比如在 onBindViewHolder 中根据数据的位置写对应的逻辑,像我上面那种我需要把最后一个数据多对应的 layout_margin 给去掉,这样也是完全没问题的,只不过如果采用了这样的方式,首先如果我们把 layout_margin 设置到每一项上,那么将来要复用这个 xml 文件,由于间距不同,我们就没法复用,或者复用也需要在代码中控制。如果使用这个,就会非常的简单,并且不会在 adapter 中再使用代码控制了。


使用这个需要进行两步:



  1. 实现自己的 ItemDecoration 子类;

  2. 添加到 recyclerView


1. 实现自己的 ItemDecoration 子类


这个类在 androidx.recyclerview.widget.RecyclerView.ItemDecoration 下:


class ItemSeparatorDecoration: RecyclerView.ItemDecoration()

这样就实现了,下面我们看看 ItemDecoration 的源代码,我把将要废弃的 API 都删掉:


abstract class ItemDecoration {
public void onDraw(Canvas c, RecyclerView parent, State state) {}
public void onDrawOver(Canvas c, RecyclerView parent, State state) {}
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {}
}

发现我们可以重写这三个函数,下面说一下这三个的含义:


1)void onDraw(Canvas c, RecyclerView parent, State state)


参数的含义:



  • Canvas c 》 canvas 绘制对象

  • RecyclerView 》 parent RecyclerView 对象本身

  • State state 》 当前 RecyclerView 的状态


作用就是绘制,可以在任何位置绘制,如果只是想绘制到每一项里面,那么就需要计算出对应的位置。


2)void onDrawOver(Canvas c, RecyclerView parent, State state)


跟上面一样,不同的地方在于绘制的总是在最上面,也就是绘制出来的不会被遮挡。


3)void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)


参数的含义:



  • Rect outRect 》item 四周的距离对象

  • View view 》 当前 view

  • RecyclerView 》 parent RecyclerView 本身

  • State state 》 RecyclerView 状态


这里可以设置 itemRecyclerView 各边的距离。这里需要说明一下,我这里说的到各边的距离指的是啥?


image.png


2. 实现上面的间隔


实现间隔是最简单的,因为我们只需要重写 getItemOffsets 函数,这个函数会在绘制每一项的时候调用,所以在这里我们只需要处理每一项的间隔,下面是重写代码,注意这里的单位并不是 dp ,而是 px ,所以如果需要使用 dp 的话,那么就需要自己转换一下,如果你不知道转换可以定义 dpdimen.xml 中,然后直接在代码中获取:


context.resources.getDimensionPixelSize(R.dimen.test_16dp)

其中 R.dimen.test_16dp 就是你定义好的值。


下面看重写的 getItemOffsets 函数:


override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
super.getItemOffsets(outRect, view, parent, state)
if (parent.getChildLayoutPosition(view) != 0) {
outRect.top = context.resources.getDimensionPixelSize(R.dimen.test_10dp)
}
}

有没有发现很简单,这样就可以实现上边的效果,只不过最常见的应该还是分割线了。


3. 实现分割线


看代码:


class MyItemDivider(val context: Context, orientation: Int) : RecyclerView.ItemDecoration() {
companion object {
// 分割线的 attr
private val ATTRS = intArrayOf(android.R.attr.listDivider)
const val HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL
const val VERTICAL_LIST = LinearLayoutManager.VERTICAL
}

// 分割线绘制所需要的 Drawable ,当然也可以直接使用 Canvas 绘制,只不过我这里使用 Drawable
private var mDivider: Drawable? = null
private var mOrientation: Int? = null

init {
val a = context.obtainStyledAttributes(ATTRS)
mDivider = a.getDrawable(0)
a.recycle()
setOrientation(orientation)
}

/**
* 设置方向,如果是 RecyclerView 是上下方向,那么这里设置 VERTICAL_LIST ,否则设置 HORIZONTAL_LIST
* @param orientation 方向
*/

private fun setOrientation(orientation: Int) {
// 传入的值必须是预先定义好的
if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
throw IllegalArgumentException("invalid orientation")
}
mOrientation = orientation
}

/**
* 开始绘制,这个函数只会执行一次,
* 所以我们在绘制的时候需要在这里把所有项的都绘制,
* 而不是只处理某一项
*/

override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
if (mOrientation == VERTICAL_LIST) {
drawVertical(c, parent)
} else {
drawHorizontal(c, parent)
}
}

private fun drawHorizontal(c: Canvas, parent: RecyclerView) {
val top = parent.paddingTop
val bottom = parent.height - parent.paddingBottom
val childCount = parent.childCount
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.right + params.rightMargin
val right = left + (mDivider?.intrinsicWidth ?: 0)
mDivider?.setBounds(left, top, right, bottom)
mDivider?.draw(c)
}
}

private fun drawVertical(c: Canvas, parent: RecyclerView) {
// 左边的距离,
// 意思是左边从哪儿开始绘制,
// 对于每一项来说,
// 肯定需要将 RecyclerView 的左边的 paddingLeft 给去掉
val left = parent.paddingLeft
// 右边就是 RecyclerView 的宽度减去 RecyclerView 右边设置的 paddingRight 值
val right = parent.width - parent.paddingRight
// 获取当前 RecyclerView 下总共有多少 Item
val childCount = parent.childCount
// 循环把每一项的都绘制完成,如果最后一项不需要,那么这里的循环就少循环一次
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
// 上边的距离就是当前 Item 下边再加上本身设置的 marginBottom
val top = child.bottom + params.bottomMargin
// 下边就简单了,就是上边 + 分割线的高度
val bottom = top + (mDivider?.intrinsicHeight ?: 0)
mDivider?.setBounds(left, top, right, bottom)
mDivider?.draw(c)
}
}

// 这个函数会被反复执行,执行的次数跟 Item 的个数相同
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
super.getItemOffsets(outRect, view, parent, state)
// 由于在上面的距离绘制,但是实际上那里不会主动为我们绘制腾出空间,
// 需要重写这个函数来手动调整空间,给上面的绘制不会被覆盖
if (mOrientation == VERTICAL_LIST) {
outRect.set(0, 0, 0, mDivider?.intrinsicHeight ?: 0)
} else {
outRect.set(0, 0, mDivider?.intrinsicWidth ?: 0, 0)
}
}
}

代码来源于刘望舒的三部曲,我对代码进行了解释和说明。大家可能在代码中的距离那一块不是很明白,直接看下面的图就很明白的。


1629513919(1).png 注意 top 我只标注了距离当前 Item 的距离,其实不是,其实是距离最上面的距离,这里这样标注是跟代码保持统一;假如上面的红色方框是我们要画的分割线,那么我们要获取的值对应上面的标注。一般 onDrawgetItemOffsets 要配合使用,如果不的话,那么你绘制的也看不见,即便看见了也是不正常的。原因我在上面讲到了, onDraw 绘制会绘制到 Item 的下面,所以如果没有留足空间的话,那么结果就是看不见绘制的内容。


内容还会补充,同时关于 RecyclerView 的将来陆续推出,真正做到完全攻略,从使用到问题解决再到源码分析。

收起阅读 »

Android输入系统之 的创建与启动

今天趁着在公司摸鱼的时间,来更新一篇文章。 上一篇文章 InputManagerService的创建与启动 分析了 IMS 的创建与启动,这其中就伴随着 InputReader 的创建与启动,本文就着重分析这两点内容。 本文所涉及的文件路径如下 fram...
继续阅读 »

今天趁着在公司摸鱼的时间,来更新一篇文章。


上一篇文章 InputManagerService的创建与启动 分析了 IMS 的创建与启动,这其中就伴随着 InputReader 的创建与启动,本文就着重分析这两点内容。


本文所涉及的文件路径如下


frameworks/native/services/inputflinger/InputManager.cpp frameworks/native/services/inputflinger/reader/EventHub.cpp frameworks/native/services/inputflinger/reader/InputReader.cpp frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp frameworks/native/services/inputflinger/InputThread.cpp


InputManagerService的创建与启动 可知,创建 InputReader 的代码如下


InputManager::InputManager(
const sp<InputReaderPolicyInterface>& readerPolicy,
const sp<InputDispatcherPolicyInterface>& dispatcherPolicy) {

// ...

mReader = createInputReader(readerPolicy, mClassifier);
}


sp<InputReaderInterface> createInputReader(const sp<InputReaderPolicyInterface>& policy,
const sp<InputListenerInterface>& listener)
{
// InputReader从EventHub中读取数据
// policy 实现类是 NativeInputManager
// listener的实现类其实是 InputClassifier
return new InputReader(std::make_unique<EventHub>(), policy, listener);
}

创建 InputReader 需要三个参数。


第一个参数的类型是 EventHub。正如名字所示,它是输入事件的中心,InputReader 会从 EventHub 中读取事件。这个事件分两类,一个是输入设备的输入事件,另一个是 EventHub 合成事件,用于表明设备的挂载与卸载。


第二个参数的类型为 InputReaderPolicyInterface,由 InputManagerService的创建与启动 可知,它的实现类是 JNI 层的 NativeInputManager。


第三个参数的类型为 InputListenerInterface, 由 InputManagerService的创建与启动 可知,它的实现类是 InputClassifier。InputReader 会的加工后的事件发送给 InputClassifier,而 InputClassifier 会针对触摸事件进行分类,再发送到 InputDispatcher。


为止防止大家有所健忘,我把上一篇文章中,关于事件的流程图,再展示下


graph TD
EventHub --> InputReader
InputReader --> NativeInputManager
InputReader --> InputClassifer
InputClassifer --> InputDispatcher
InputDispatcher --> NativeInputManager
NativeInputManager --> InputManagerService

创建EventHub


创建 InputReader 首先需要一个 EventHub 对象,因此我们首先得看下 EventHub 的创建过程


EventHub::EventHub(void)
: mBuiltInKeyboardId(NO_BUILT_IN_KEYBOARD),
mNextDeviceId(1),
mControllerNumbers(),
mOpeningDevices(nullptr),
mClosingDevices(nullptr),
mNeedToSendFinishedDeviceScan(false),
mNeedToReopenDevices(false),
mNeedToScanDevices(true),
mPendingEventCount(0),
mPendingEventIndex(0),
mPendingINotify(false) {
ensureProcessCanBlockSuspend();

// 创建epoll
mEpollFd = epoll_create1(EPOLL_CLOEXEC);

// 初始化inotify
mINotifyFd = inotify_init();
// 1. 使用inotify监听/dev/input目录下文件的创建与删除事件
mInputWd = inotify_add_watch(mINotifyFd, DEVICE_PATH, IN_DELETE | IN_CREATE);
// ...

struct epoll_event eventItem = {};
eventItem.events = EPOLLIN | EPOLLWAKEUP;
eventItem.data.fd = mINotifyFd;
// 2. epoll监听inotify可读事件
int result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mINotifyFd, &eventItem);

// 3. 创建两个管道
int wakeFds[2];
result = pipe(wakeFds);

mWakeReadPipeFd = wakeFds[0];
mWakeWritePipeFd = wakeFds[1];

result = fcntl(mWakeReadPipeFd, F_SETFL, O_NONBLOCK);

result = fcntl(mWakeWritePipeFd, F_SETFL, O_NONBLOCK);

eventItem.data.fd = mWakeReadPipeFd;
// 4. epoll监听mWakeReadPipeFd可读事件
result = epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeReadPipeFd, &eventItem);
}

第一步和第二步,初始化 inotify 监听 /dev/input/ 目录下的文件的创建和删除事件,然后使用 epoll 管理这个 inotify。


为何要使用 inotify 监听 /dev/input/ 目录呢,因为当输入设备挂载和卸载时,内核会相应地在这个目录下创建和删除设备文件,因此监听这个目录可获知当前有哪些输入设备,然后才能监听这些设备的输入事件。


第三步和第四步,创建了两个管道,其中一个管道也被 epoll 管理起来,这个管道是用来唤醒 InputReader 线程。例如当配置发生改变时,这个管道会被用来唤醒 InputReader 线程来处理配置的改变。



另一个管道用于做什么呢?



现在 epoll 已经管理了两个文件描述符,mINotifyFd 和 mWakeReadPipeFd。但是现在并没有启动 epoll 来监听它们的可读事件,这是因为 InputReader 还没有准备好,让我们继续往下看。



本文不想浪费篇幅去介绍 Linux inotify 和 epoll 机制,这两个机制并不复杂,请大家自己去了解。



创建 InputReader


EventHub 已经创建完毕,现在来看下创建 InputReader 的过程


InputReader::InputReader(std::shared_ptr<EventHubInterface> eventHub,
const sp<InputReaderPolicyInterface>& policy,
const sp<InputListenerInterface>& listener)
: mContext(this), // ContextImpl mContext 是一个关于 InputReader 的环境
mEventHub(eventHub),
mPolicy(policy), // 由 NativeInputManager实现
mGlobalMetaState(0),
mGeneration(1),
mNextInputDeviceId(END_RESERVED_ID),
mDisableVirtualKeysTimeout(LLONG_MIN),
mNextTimeout(LLONG_MAX),
mConfigurationChangesToRefresh(0) {
// 1. 创建QueuedInputListener对象
// 事件的转换都是通过 InputListenerInterface 接口
// QueuedInputListener 是一个继承并实现了 InputListenerInterface 接口的代理类
// QueuedInputListener 把事件加入队列,并推迟发送事件直到调用它的flush()函数
mQueuedListener = new QueuedInputListener(listener);

{ // acquire lock
AutoMutex _l(mLock);
// 2. 更新配置,保存到mConfig中
refreshConfigurationLocked(0);
// 根据已经映射的设备,更新 mGlobalMetaState 的值
// 由于目前还没有映射设备,所以mGlobalMetaState值为0
updateGlobalMetaStateLocked();
} // release lock
}

InputReader 构造函数看似平平无奇,实际上有许多值得注意的地方。


首先注意 mContext 变量,它的类型是 ContextImpl,这是一个表示 InputReader 的环境,由于 ContextImpl 是 InputReader 的友元类,因此透过 ContextImpl 可以访问 InputReader 的私有数据。


那么这个 mContext 变量被谁所用呢? InputReader 会为物理输入设备建立一个映射类 InputDevice,这个 InputDevice 就会保存这个 mContext 变量,InputDevice 会通过 mContext,从 InputReader 中获取全局的设备状态以及参数。


InputReader 构造函数使用了一个 InputClassifier 接口对象,由 InputManagerService的创建与启动 可知,InputListenerInterfac 接口的实现类是 InputClassifier。 实际上,InputListenerInterface 接口是专为传递事件设计的。因此只要你看到哪个类实现 ( 在c++中叫继承 ) 了 InputListenerInterface 这个接口,那么它肯定是传递事件中的一环。


mQueuedListener 变量的类型是 QueuedInputListener ,恰好这个类也实现了 InputListenerInterface 接口,那么它肯定也传递事件。然而 QueuedInputListener 只是一个代理类,InputReader 会把事件存储到 QueuedInputListener 的队列中,然后直到 QueuedInputListener::flush() 函数被调用,QueuedInputListener 才把队列中的事件发送出去。发送给谁呢,就是 InputClassifier。


那么现在我们来总结下,事件通过 InputListenerInterface 接口传递的关系图


graph TD
InputReader --> |InputListenerInterface|QueuedInputListener
QueuedInputListener --> |InputListenerInterface|InputClassifier
InputClassifier --> |InputListenerInterface|InputDispatcher

最后,我们来一件挺烦琐的小事,InputReader 读取配置,它调用的是如下代码


// 注意,参数 changes 值为0
void InputReader::refreshConfigurationLocked(uint32_t changes) {
// 从NativeInputManager中获取配置,保存到mConfig中
mPolicy->getReaderConfiguration(&mConfig);
// EventHub保存排除的输入设备
mEventHub->setExcludedDevices(mConfig.excludedDeviceNames);

if (changes) {
// ...
}
}

mPolicy 的实现类是 JNI 层 NativeInputManager,由 InputManagerService的创建与启动 可知, NativeInputManager 只是一个桥梁作用,那么它肯定是向上层的 InputManagerService 获取配置,是不是这样呢,来验证下。


void NativeInputManager::getReaderConfiguration(InputReaderConfiguration* outConfig) {
ATRACE_CALL();
JNIEnv* env = jniEnv();

// 0
jint virtualKeyQuietTime = env->CallIntMethod(mServiceObj,
gServiceClassInfo.getVirtualKeyQuietTimeMillis);
if (!checkAndClearExceptionFromCallback(env, "getVirtualKeyQuietTimeMillis")) {
outConfig->virtualKeyQuietTime = milliseconds_to_nanoseconds(virtualKeyQuietTime);
}

outConfig->excludedDeviceNames.clear();
// 如下两个文件定义了排除的设备
// /system/etc/excluded-input-devices.xml
// /vendor/etc/excluded-input-devices.xml
jobjectArray excludedDeviceNames = jobjectArray(env->CallStaticObjectMethod(
gServiceClassInfo.clazz, gServiceClassInfo.getExcludedDeviceNames));
if (!checkAndClearExceptionFromCallback(env, "getExcludedDeviceNames") && excludedDeviceNames) {
jsize length = env->GetArrayLength(excludedDeviceNames);
for (jsize i = 0; i < length; i++) {
std::string deviceName = getStringElementFromJavaArray(env, excludedDeviceNames, i);
outConfig->excludedDeviceNames.push_back(deviceName);
}
env->DeleteLocalRef(excludedDeviceNames);
}

// Associations between input ports and display ports
// The java method packs the information in the following manner:
// Original data: [{'inputPort1': '1'}, {'inputPort2': '2'}]
// Received data: ['inputPort1', '1', 'inputPort2', '2']
// So we unpack accordingly here.
// 输入端口和显示端口绑定的关系,一种是静态绑定,来自于/vendor/etc/input-port-associations.xml
// 而另一种是来自于运行时的动态绑定,并且动态绑定可以覆盖静态绑定。
outConfig->portAssociations.clear();
jobjectArray portAssociations = jobjectArray(env->CallObjectMethod(mServiceObj,
gServiceClassInfo.getInputPortAssociations));
if (!checkAndClearExceptionFromCallback(env, "getInputPortAssociations") && portAssociations) {
jsize length = env->GetArrayLength(portAssociations);
for (jsize i = 0; i < length / 2; i++) {
std::string inputPort = getStringElementFromJavaArray(env, portAssociations, 2 * i);
std::string displayPortStr =
getStringElementFromJavaArray(env, portAssociations, 2 * i + 1);
uint8_t displayPort;
// Should already have been validated earlier, but do it here for safety.
bool success = ParseUint(displayPortStr, &displayPort);
if (!success) {
ALOGE("Could not parse entry in port configuration file, received: %s",
displayPortStr.c_str());
continue;
}
outConfig->portAssociations.insert({inputPort, displayPort});
}
env->DeleteLocalRef(portAssociations);
}

// 下面这些都与悬浮点击有关系,如果触摸屏支持悬浮点击,可以研究下这些参数
jint hoverTapTimeout = env->CallIntMethod(mServiceObj,
gServiceClassInfo.getHoverTapTimeout);
if (!checkAndClearExceptionFromCallback(env, "getHoverTapTimeout")) {
jint doubleTapTimeout = env->CallIntMethod(mServiceObj,
gServiceClassInfo.getDoubleTapTimeout);
if (!checkAndClearExceptionFromCallback(env, "getDoubleTapTimeout")) {
jint longPressTimeout = env->CallIntMethod(mServiceObj,
gServiceClassInfo.getLongPressTimeout);
if (!checkAndClearExceptionFromCallback(env, "getLongPressTimeout")) {
outConfig->pointerGestureTapInterval = milliseconds_to_nanoseconds(hoverTapTimeout);

// We must ensure that the tap-drag interval is significantly shorter than
// the long-press timeout because the tap is held down for the entire duration
// of the double-tap timeout.
jint tapDragInterval = max(min(longPressTimeout - 100,
doubleTapTimeout), hoverTapTimeout);
outConfig->pointerGestureTapDragInterval =
milliseconds_to_nanoseconds(tapDragInterval);
}
}
}

// 悬浮移动距离
jint hoverTapSlop = env->CallIntMethod(mServiceObj,
gServiceClassInfo.getHoverTapSlop);
if (!checkAndClearExceptionFromCallback(env, "getHoverTapSlop")) {
outConfig->pointerGestureTapSlop = hoverTapSlop;
}

// 如下mLocked的相关参数是在 NativeInputManager 的构造函数中初始化的
// 但是这些参数都是可以通过 InputManagerService 改变的
{ // acquire lock
AutoMutex _l(mLock);

outConfig->pointerVelocityControlParameters.scale = exp2f(mLocked.pointerSpeed
* POINTER_SPEED_EXPONENT);
outConfig->pointerGesturesEnabled = mLocked.pointerGesturesEnabled;

outConfig->showTouches = mLocked.showTouches;

outConfig->pointerCapture = mLocked.pointerCapture;

outConfig->setDisplayViewports(mLocked.viewports);

outConfig->defaultPointerDisplayId = mLocked.pointerDisplayId;

outConfig->disabledDevices = mLocked.disabledInputDevices;
} // release lock
}

从这里可以看出,InputReader 获取配置的方式,是通过 JNI 层的 NativeInputManager 向 Java 层的 InputManagerService 获取的。


但是这些配置并不是不变的,当Java层改变这些配置后,会通过 JNI 层的 NativeInputManager 通知 InputReader ( 注意,不是InputReader线程 ),然后通过 EventHub::wake() 函数通过管道唤醒 InputReader 线程来处理配置改变。这个过程可以在阅读完本文后,自行分析。


启动 InputReader


现在 InputReader 已经创建完毕,让我们继续看下它的启动过程。


InputManagerService的创建与启动 可知,启动 InputReader 的代码如下


status_t InputReader::start() {
if (mThread) {
return ALREADY_EXISTS;
}
// 创建线程并启动
mThread = std::make_unique<InputThread>(
"InputReader", [this]() { loopOnce(); }, [this]() { mEventHub->wake(); });
return OK;
}

InputThread 封装了 c++ 的 Thread 类


class InputThreadImpl : public Thread {
public:
explicit InputThreadImpl(std::function<void()> loop)
: Thread(/* canCallJava */ true), mThreadLoop(loop) {
}

~InputThreadImpl() {}

private:
std::function<void()> mThreadLoop;

bool threadLoop() override {
mThreadLoop();
return true;
}
};

当 InputThread 对象创建的时候,会启动一个线程


InputThread::InputThread(std::string name, std::function<void()> loop, std::function<void()> wake)
: mName(name), mThreadWake(wake) {
mThread = new InputThreadImpl(loop);
mThread->run(mName.c_str(), ANDROID_PRIORITY_URGENT_DISPLAY);
}

线程会循环调用 loopOnce() 函数,也就是 InputThread 构造函数的第二个参数,它的实现函数是 InputReader::loopOnce() 函数。


我们注意到 InputThread 构造函数还有第三个参数,它是在 InputThread 析构函数调用的。


InputThread::~InputThread() {
mThread->requestExit();
// mThreadWake 就是构造函数中的第三个参数
if (mThreadWake) {
mThreadWake();
}
mThread->requestExitAndWait();
}

那么什么时候会调用 InputThread 的析构函数呢,我觉得应该是 system_server 进程挂掉的时候,此时会调用 EventHub::wake() 来唤醒 InputReader 线程,从而退出 InputReader 线程。而这个唤醒的方式,就是使用刚才在 EventHub 中创建的一个管道。


现在来分析下 InputReader::loopOnce() 函数,这里就是 InputReader 线程所做的事


void InputReader::loopOnce() {
int32_t oldGeneration;
int32_t timeoutMillis;
bool inputDevicesChanged = false;
std::vector<InputDeviceInfo> inputDevices;

// 1. 处理配置改变
{ // acquire lock
AutoMutex _l(mLock);
oldGeneration = mGeneration;
timeoutMillis = -1;
uint32_t changes = mConfigurationChangesToRefresh;
if (changes) {
mConfigurationChangesToRefresh = 0;
timeoutMillis = 0;
refreshConfigurationLocked(changes);
} else if (mNextTimeout != LLONG_MAX) { // mNextTimeout 也属于配置
nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);
timeoutMillis = toMillisecondTimeoutDelay(now, mNextTimeout);
}
} // release lock

// 2. 读取事件
size_t count = mEventHub->getEvents(timeoutMillis, mEventBuffer, EVENT_BUFFER_SIZE);

// 3. 处理事件
{ // acquire lock
AutoMutex _l(mLock);
mReaderIsAliveCondition.broadcast();

// 如果读到事件,就处理
if (count) {
processEventsLocked(mEventBuffer, count);
}

// 处理超时情况
if (mNextTimeout != LLONG_MAX) {
// ...
}

// mGeneration 表明输入设备改变
if (oldGeneration != mGeneration) {
inputDevicesChanged = true;
// 对inputDevices填充inputDeviceInfo,而这个InputDeviceInfo是从InputDevice中获取
getInputDevicesLocked(inputDevices);
}
} // release lock

// 4. 通知设备改变
if (inputDevicesChanged) {
// mPolicy实现类为NativeInputManager
mPolicy->notifyInputDevicesChanged(inputDevices);
}

// 5. 事件发送给 InputClassifier。
mQueuedListener->flush();
}

我第一次看到这段代码时,头皮发麻,InputReader 做了这么多事情,我该怎么分析呢?不要紧,让我来梳理下思路。


首先看第一步,这一步是处理配置改变。前面我们谈论过这个话题,当配置发生改变时,一般都通过 Java 层的 InputManagerService 发送信息给 JNI 层的 NativeInputManager ,然后再通知 InputReader (注意不是InputReader线程),InputReader 会通过 EventHub::wake() 函数来唤醒 InputReader 线程来处理配置改变。这就是第一步做的事件。鉴于篇幅原因,这个过程就不分析了。


第二步,从 EventHub 获取数据。这个获取数据的过程其实分三种情况。



  1. 第一种情况,发生在系统首次启动,并且没有输入事件发生,例如手指没有在触摸屏上滑动。EventHub 会扫描输入设备,并建立与输入设备相应的数据结构,然后创建多个 EventHub 自己合成的事件,最后把这些事件返回给 InputReader 线程。为何要扫描设备,前面已经说过,是为了监听输入设备事件。


  2. 第二种情况,发生在系统启动完毕,然后有输入事件,例如手指在触摸屏上滑动。EventHub 会把 /dev/input/ 目录下的设备文件中的原始数据,包装成一个事件,发送给 InputReader 线程处理。


  3. 第三种情况,系统在运行的过程中,发生设备的挂载和卸载,EventHub 也会像第一种情况一样,合成自己的事件,并发送给 InputReader 线程处理。其实第一种情况和第三种情况下,InputReader 线程对事件的处理是类似的。因此后面的文章并不会分析这种情况。



第三步,获取完事件后,就处理这些事件。


第四步,通知监听者,设备发生改变。谁是监听者呢,就是上层的 InputManagerService。


第五步,把InpuReader加工好的事件发送给 InputClassifier。


事件发送关系图


经过本文的分析,我们可以得出一张事件发送关系图,以及各个组件如何通信的关系图


graph TD
EventHub --> |EventHub::getEvent|InputReader
InputReader --> |InputReaderPolicyInterface|NativeInputManager
InputReader --> |InputListenerInterface|InputClassifer
InputClassifer --> |InputListenerInterface|InputDispatcher
InputDispatcher --> |InputDispatcherPolicyInterface|NativeInputManager
NativeInputManager --> |mServiceObj|InputManagerService

结束


简简单单的 InputReader 的创建与启动就分析完了,而本文仅仅是描述了一个轮廓,但是我就问你复杂不复杂?复杂吧,不过没关系,只要我们理清思路,我们就一步一步来。那么下篇文章,我们来分析系统启动时,EventHub 是如何扫描设备并发送合成事件,以及 InputReader 线程是如何处理这些合成事件。


收起阅读 »

Android OpenGL ES 实现抖音传送带特效

抖音 APP 真是个好东西,不过也容易上瘾,老实说你的抖音是不是反复卸载又反复安装了,后来我也发现我的几个 leader 都不刷抖音,这令我挺吃惊的。 我刷抖音主要是为了看新闻,听一些大 V 讲历史,研究抖音的一些算法特效,最重要的是抖音提供了一个年轻人的视...
继续阅读 »

抖音 APP 真是个好东西,不过也容易上瘾,老实说你的抖音是不是反复卸载又反复安装了,后来我也发现我的几个 leader 都不刷抖音,这令我挺吃惊的。


我刷抖音主要是为了看新闻,听一些大 V 讲历史,研究抖音的一些算法特效,最重要的是抖音提供了一个年轻人的视角去观察世界。另外,自己感兴趣的内容看多了,反而训练抖音推送更多类似的优质内容,大家可以反向利用抖音的这一特点。


至于我的 leader 老是强调刷抖音不好,对此我并不完全认同。


实现抖音传送带特效 传送带


抖音传送带特效原理


抖音传送带特效推出已经很长一段时间了,前面也实现了下,最近把它整理出来了,如果你有仔细观测传送带特效,就会发现它的实现原理其实很简单。


传送带原理.png


通过仔细观察抖音的传送带特效,你可以发现左侧是不停地更新预览画面,右侧看起来就是一小格一小格的竖条状图像区域不断地向右移动,一直移动到右侧边界位置。


预览的时候每次拷贝一小块预览区域的图像送到传送带,这就形成了源源不断地向右传送的效果。


原理图进行了简化处理, 实际上右侧的竖条图像更多,效果会更流畅,每来一帧预览图像,首先拷贝更新左侧预览画面,然后从最右侧的竖条图像区域开始拷贝图像(想一想为什么?)。


例如将区域 2 的像素拷贝到区域 3 ,然后将区域 1 的像素拷贝到区域 2,以此类推,最后将来源区域的像素拷贝到区域 0 。


这样就形成了不断传送的效果,最后将拷贝好的图像更新到纹理,利用 OpenGL 渲染到屏幕上。


抖音传送带特效实现


抖音传送带特效实现


上节原理分析时,将图像区域从左侧到右侧拷贝并不高效,可能会导致一些性能问题,好在 Android 相机出图都是横向的(旋转了 90 或 270 度),这样图像区域上下拷贝效率高了很多,最后渲染的时候再将图像旋转回来。


Android 相机出图是 YUV 格式的,这里为了拷贝处理方便,先使用 OpenCV 将 YUV 图像转换为 RGBA 格式,当然为了追求性能直接使用 YUV 格式的图像问题也不大。


cv::Mat mati420 = cv::Mat(pImage->height * 3 / 2, pImage->width, CV_8UC1, pImage->ppPlane[0]);
cv::Mat matRgba = cv::Mat(m_SrcImage.height, m_SrcImage.width, CV_8UC4, m_SrcImage.ppPlane[0]);
cv::cvtColor(mati420, matRgba, CV_YUV2RGBA_I420);

用到的着色器程序就是简单的贴图:


#version 300 es
layout(location = 0) in vec4 a_position;
layout(location = 1) in vec2 a_texCoord;
uniform mat4 u_MVPMatrix;
out vec2 v_texCoord;
void main()
{
gl_Position = u_MVPMatrix * a_position;
v_texCoord = a_texCoord;
}

#version 300 es
precision mediump float;
in vec2 v_texCoord;
layout(location = 0) out vec4 outColor;
uniform sampler2D u_texture;

void main()
{
outColor = texture(u_texture, v_texCoord);
}

传送带的核心就是图像拷贝操作:


memcpy(m_RenderImage.ppPlane[0], m_SrcImage.ppPlane[0], m_RenderImage.width * m_RenderImage.height * 4 / 2); //左侧预览区域像素拷贝

int bannerHeight = m_RenderImage.height / 2 / m_bannerNum;//一个 banner 的高(小竖条)
int bannerPixelsBufSize = m_RenderImage.width * bannerHeight * 4;//一个 banner 占用的图像内存

uint8 *pBuf = m_RenderImage.ppPlane[0] + m_RenderImage.width * m_RenderImage.height * 4 / 2; //传送带分界线

//从最右侧的竖条图像区域开始拷贝图像
for (int i = m_bannerNum - 1; i >= 1; --i) {
memcpy(pBuf + i*bannerPixelsBufSize, pBuf + (i - 1)*bannerPixelsBufSize, bannerPixelsBufSize);
}

//将来源区域的像素拷贝到竖条图像区域 0
memcpy(pBuf, pBuf - bannerPixelsBufSize, bannerPixelsBufSize);

渲染操作:


glUseProgram (m_ProgramObj);

glBindVertexArray(m_VaoId);

glUniformMatrix4fv(m_MVPMatLoc, 1, GL_FALSE, &m_MVPMatrix[0][0]);

//图像拷贝,传送带拷贝
memcpy(m_RenderImage.ppPlane[0], m_SrcImage.ppPlane[0], m_RenderImage.width * m_RenderImage.height * 4 / 2);
int bannerHeight = m_RenderImage.height / 2 / m_bannerNum;
int bannerPixelsBufSize = m_RenderImage.width * bannerHeight * 4;

uint8 *pBuf = m_RenderImage.ppPlane[0] + m_RenderImage.width * m_RenderImage.height * 4 / 2; //传送带分界线

for (int i = m_bannerNum - 1; i >= 1; --i) {
memcpy(pBuf + i*bannerPixelsBufSize, pBuf + (i - 1)*bannerPixelsBufSize, bannerPixelsBufSize);
}
memcpy(pBuf, pBuf - bannerPixelsBufSize, bannerPixelsBufSize);

//更新纹理
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_TextureId);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, m_RenderImage.width, m_RenderImage.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, m_RenderImage.ppPlane[0]);
glBindTexture(GL_TEXTURE_2D, GL_NONE);

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_TextureId);
GLUtils::setInt(m_ProgramObj, "u_texture", 0);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, (const void *)0);
glBindVertexArray(GL_NONE);

详细实现代码见项目:github.com/githubhaoha…

收起阅读 »

自动化检测 Android APP 非 SDK 接口使用,防止非预期异常发生!

背景 从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制,只要应用引用非 SDK 接口或尝试使用反射或 JNI 来获取其句柄,这些限制就适用,这些限制旨在帮助提升用户体验和开发者体验,为用户降低应用...
继续阅读 »

背景


从 Android 9(API 级别 28)开始,Android 平台对应用能使用的非 SDK 接口实施了限制,只要应用引用非 SDK 接口或尝试使用反射或 JNI 来获取其句柄,这些限制就适用,这些限制旨在帮助提升用户体验和开发者体验,为用户降低应用发生崩溃的风险,同时为开发者降低紧急发布的风险。


区分 SDK 接口和非 SDK 接口


一般而言,公共 SDK 接口是在 Android 框架软件包索引中记录的那些接口,非 SDK 接口的处理是 API 抽象出来的实现细节,因此这些接口可能会在不另行通知的情况下随时发生更改。 


为了避免发生崩溃和意外行为,应用应仅使用 SDK 中经过正式记录的类,这也意味着当您的应用通过反射等机制与类互动时,不应访问 SDK 中未列出的方法或字段。


非 SDK API 名单


为最大程度地降低非 SDK 使用限制对开发工作流的影响,Google 将非 SDK 接口分成了几个名单,这些名单界定了非 SDK 接口使用限制的严格程度(取决于应用的目标 API 级别):



  • greylist 无限制,可以正常使用

  • blacklist 无论什么版本的手机系统,使用这些api,系统将会抛出异常

  • greylist-max-o 受限制的灰名单,APP运行在 版本<=8.0的系统里 可以正常访问,targetSDK>8.0且运行在>8.0的手机会抛出异常

  • greylist-max-p 受限制的灰名单,APP运行在 版本<=9.0的系统里 可以正常访问,targetSDK>9.0且运行在>9.0的手机会抛出异常

  • greylist-max-q 受限制的灰名单,受限制的灰名单。APP运行在 版本<=10.0的系统里 可以正常访问,targetSDK>10.0且运行在>10.0的手机会抛出异常


测试你的应用是否使用了非 SDK 接口


这里我们通过veridex工具进行测试,veridex 工具会扫描 APK 的整个代码库(包括所有第三方库),并报告发现的所有使用非 SDK 接口的行为。


不过veridex 工具存在以下局限性:



  • 它无法检测到通过 JNI 实现的调用

  • 它只能检测到一部分通过反射实现的调用

  • 它对非活动代码路径的分析仅限于 API 级别的检查

  • 它只能在支持 SSE4.2 和 POPCNT 指令的机器上运行


我们以Mac系统为例,首先我们需要下载veridex 工具:android.googlesource.com/platform/pr…


然后解压缩 appcompat.tar.gz 文件的内容,在解压缩的文件夹中,找到 veridex-mac.zip 文件并将其解压缩,转到解压缩的文件夹,然后运行下面的命令,其中 /path-from-root/your-app.apk 是你要测试的 APK 的路径,从系统的根目录开始:


./appcompat.sh --dex-file=/path-from-root/your-app.apk

文件夹中的hiddenapi-flags.csv文件是需要根据targetAPI版本来更新的,不同的版本会有不同的检查清单,具体可参考:


https://developer.android.google.cn/distribute/best-practices/develop/restrictions-non-sdk-interfaces#determine-list

报告


生成的报告如下图,我们主要关注红框部分的内容就可以了,如果存在blacklist的接口一定是需要修复的:


图片

收起阅读 »

【Flutter 组件集录】SizedBox

一、认识 SizedBox 组件 源码中对 SizedBox 的介绍为:一个指定尺寸的盒子。那 SizedBox 为什么可以限定尺寸?背后区域限定的原理又是什么? 本文通过 SizedBox 来一窥布局约束奥秘的冰山一角。 1.SizedBox 基...
继续阅读 »
一、认识 SizedBox 组件

源码中对 SizedBox 的介绍为:一个指定尺寸的盒子。那 SizedBox 为什么可以限定尺寸?背后区域限定的原理又是什么? 本文通过 SizedBox 来一窥布局约束奥秘的冰山一角。





1.SizedBox 基本信息

下面是 SizedBox 组件类的定义构造方法,可以看出它继承自 SingleChildRenderObjectWidget。可接受一个子组件,和区域的宽高。





2.SizedBox 的使用

如下,是一个 100*50SizedBox ,通过 ColoredBox 涂上蓝色,效果如下:



SizedBox(
width: 100,
height: 50,
child: ColoredBox(
color: Colors.blue.withAlpha(88)
),
),



3.区域分析

乍一看,不就是一个组件提供宽高来设置尺寸吗,似乎并没有什么好延伸的。但你有没有想过,为什么 SizedBox 有权力决定尺寸大小?它决定的区域一定有效吗?在分析之前,先了解一些前置知识:


任何组件的占位区域绘制内容最终都取决于 RenderObject 。而并非所有的组件都和 RenderObject 有关,只有 RenderObjectWidget 负责维护 RenderObject 。像 StatelessWidgetStatefulWidget 这种都是基于已有组件进行组合,往深层去看,他们都是基于某些 RenderObjectWidget 实现。


关于布局, RenderObject 有一个非常重要的属性: Constraints 类型的 constraints ,表示自身受到的区域约束限制。而 RenderBox 作为 RenderObject 的子类,拓展出了 size 的概念,绝大多数组件维护的渲染对象都是在 RenderBox 基础上进行拓展的。


下面来打开组件树,一起来看一下:



上面的 SizedBox 组件,它维护的 RenderObjectRenderConstrainedBox ,自身的约束为 [w(0,800) - h(0,600)] ,也就说明该渲染对象的大小必须在这此区间内。然后它会给子组件施加一个额外的约束 [w(100,100) - h(50,50)]


这样对于 ColoredBox 对应的渲染对象 _RenderColoredBox ,由于父级施加的额外约束,自身的约束也就变成 [w(100,100) - h(50,50)] 。也就说明该渲染对象的大小必须在这此区间内,即 _RenderColoredBox 的尺寸被限定为 (100,50)


_RenderColoredBoxsize 确定后,RenderConstrainedBox 会根据自身的约束和子节点的尺寸来确定自身的尺寸。这就是 SizedBox 的工作原理。




4、约束测试

为了更好地说明约束的作用,这里进行一下测试,在之前的案例的 SizedBox 外层通过 ConstrainedBox 组件添加添加一个 [w(20,20) - h(20,20)] 的强制约束。可以看出即使 SizedBox 设置了固定的宽高,但是在外层的约束之下,会优先满足父级约束。


[推论1] SizedBox 的最终尺寸会受到父级约束的影响,并非一定为指定值。


ConstrainedBox(
constraints: BoxConstraints(
minWidth: 20,
maxWidth: 20,
maxHeight: 20,
minHeight: 20,
),
child: SizedBox(
width: 100,
height: 50,
child: ColoredBox(color: Colors.blue.withAlpha(88)),
),
);



我们再来看一下此时的组件树:
可以看出 SizedBox 维护的 RenderConstrainedBox 本身的约束区域为 [w(20,20) - h(20,20)] ,为子节点施加的额外约束为 [w(100,100) - h(50,50)] 。在 ColoredBox 维护的 _RenderColoredBox 中,约束区域为 [w(20,20) - h(20,20)] ,这也就觉得了其尺寸为 (20,20)



这样可以看出,渲染对象对子节点施加的额外约束 ,并不会完全作用于子节点。还会根据自身的约束情况,来确定子组件的最终约束。




三、SizedBox 的源码分析


SizedBox 继承自 SingleChildRenderObjectWidget ,就说明它需要维护一个 RenderObject 来实现功能。





在前面我们通过组件树可以看出,它维护的渲染对象是 RenderConstrainedBox 。从源码中可以看出, RenderConstrainedBox 构造时需要传入一个约束对象 BoxConstraints 。这里通过 BoxConstraints.tightFor 构造使用 widthheight 创建一个紧约束。



通过源码可以看出,这个构造的约束为: [w(width,width) - h(height,height)],也就是固定宽高约束。





SizedBox 除了普通构造之外,还有三个命名构造。如果已经了解上面的用法,那这三个也非常简单,都逃离不了对宽高的初始化。比如 .expand 会创建一个无限的约束,这样由于 推论1 ,其约束的尺寸就可以在父级的约束下,尽可能的大 。 .shrink 就是一个 [w(0,0) - h(0,0)]的限制,同理,会在父级的约束下,尽可能的小。



至于 RenderConstrainedBox 渲染对象的实现,将在后面的 ConstrainedBox 一文中进行介绍,毕竟 RenderConstrainedBox 的本命是 ConstrainedBox 。通过本文,你应该对 SizedBox 有了更深的认识,对布局约束、尺寸确定也认识了九牛一毛 。那本文到这里就结束了,谢谢观看,明天见~

收起阅读 »

iOS 专用图层 二

6.2 CATextLayer用户界面是无法从一个单独的图片里面构建的。一个设计良好的图标能够很好地表现一个按钮或控件的意图,不过你迟早都要需要一个不错的老式风格的文本标签。如果你想在一个图层里面显示文字,完全可以借助图层代理直接将字符串使用Core Grap...
继续阅读 »

6.2 CATextLayer

用户界面是无法从一个单独的图片里面构建的。一个设计良好的图标能够很好地表现一个按钮或控件的意图,不过你迟早都要需要一个不错的老式风格的文本标签。

如果你想在一个图层里面显示文字,完全可以借助图层代理直接将字符串使用Core Graphics写入图层的内容(这就是UILabel的精髓)。如果越过寄宿于图层的视图,直接在图层上操作,那其实相当繁琐。你要为每一个显示文字的图层创建一个能像图层代理一样工作的类,还要逻辑上判断哪个图层需要显示哪个字符串,更别提还要记录不同的字体,颜色等一系列乱七八糟的东西。

万幸的是这些都是不必要的,Core Animation提供了一个CALayer的子类CATextLayer,它以图层的形式包含了UILabel几乎所有的绘制特性,并且额外提供了一些新的特性。

同样,CATextLayer也要比UILabel渲染得快得多。很少有人知道在iOS 6及之前的版本,UILabel其实是通过WebKit来实现绘制的,这样就造成了当有很多文字的时候就会有极大的性能压力。而CATextLayer使用了Core text,并且渲染得非常快。

让我们来尝试用CATextLayer来显示一些文字。清单6.2的代码实现了这一功能,结果如图6.2所示。

清单6.2 用CATextLayer来实现一个UILabel

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *labelView;

@end

@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];

//create a text layer
CATextLayer *textLayer = [CATextLayer layer];
textLayer.frame = self.labelView.bounds;
[self.labelView.layer addSublayer:textLayer];

//set text attributes
textLayer.foregroundColor = [UIColor blackColor].CGColor;
textLayer.alignmentMode = kCAAlignmentJustified;
textLayer.wrapped = YES;

//choose a font
UIFont *font = [UIFont systemFontOfSize:15];

//set layer font
CFStringRef fontName = (__bridge CFStringRef)font.fontName;
CGFontRef fontRef = CGFontCreateWithFontName(fontName);
textLayer.font = fontRef;
textLayer.fontSize = font.pointSize;
CGFontRelease(fontRef);

//choose some text
NSString *text = @"Lorem ipsum dolor sit amet, consectetur adipiscing \ elit. Quisque massa arcu, eleifend vel varius in, facilisis pulvinar \ leo. Nunc quis nunc at mauris pharetra condimentum ut ac neque. Nunc elementum, libero ut porttitor dictum, diam odio congue lacus, vel \ fringilla sapien diam at purus. Etiam suscipit pretium nunc sit amet \ lobortis";

//set layer text
textLayer.string = text;
}
@end

图6.2

图6.2 用CATextLayer来显示一个纯文本标签

如果你仔细看这个文本,你会发现一个奇怪的地方:这些文本有一些像素化了。这是因为并没有以Retina的方式渲染,第二章提到了这个contentScale属性,用来决定图层内容应该以怎样的分辨率来渲染。contentsScale并不关心屏幕的拉伸因素而总是默认为1.0。如果我们想以Retina的质量来显示文字,我们就得手动地设置CATextLayercontentsScale属性,如下:

textLayer.contentsScale = [UIScreen mainScreen].scale;

这样就解决了这个问题(如图6.3)

图6.3

图6.3 设置contentsScale来匹配屏幕

CATextLayerfont属性不是一个UIFont类型,而是一个CFTypeRef类型。这样可以根据你的具体需要来决定字体属性应该是用CGFontRef类型还是CTFontRef类型(Core Text字体)。同时字体大小也是用fontSize属性单独设置的,因为CTFontRefCGFontRef并不像UIFont一样包含点大小。这个例子会告诉你如何将UIFont转换成CGFontRef

另外,CATextLayerstring属性并不是你想象的NSString类型,而是id类型。这样你既可以用NSString也可以用NSAttributedString来指定文本了(注意,NSAttributedString并不是NSString的子类)。属性化字符串是iOS用来渲染字体风格的机制,它以特定的方式来决定指定范围内的字符串的原始信息,比如字体,颜色,字重,斜体等。

富文本

iOS 6中,Apple给UILabel和其他UIKit文本视图添加了直接的属性化字符串的支持,应该说这是一个很方便的特性。不过事实上从iOS3.2开始CATextLayer就已经支持属性化字符串了。这样的话,如果你想要支持更低版本的iOS系统,CATextLayer无疑是你向界面中增加富文本的好办法,而且也不用去跟复杂的Core Text打交道,也省了用UIWebView的麻烦。

让我们编辑一下示例使用到NSAttributedString(见清单6.3).iOS 6及以上我们可以用新的NSTextAttributeName实例来设置我们的字符串属性,但是练习的目的是为了演示在iOS 5及以下,所以我们用了Core Text,也就是说你需要把Core Text framework添加到你的项目中。否则,编译器是无法识别属性常量的。

图6.4是代码运行结果(注意那个红色的下划线文本)

清单6.3 用NSAttributedString实现一个富文本标签。

#import "DrawingView.h"
#import
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *labelView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//create a text layer
CATextLayer *textLayer = [CATextLayer layer];
textLayer.frame = self.labelView.bounds;
textLayer.contentsScale = [UIScreen mainScreen].scale;
[self.labelView.layer addSublayer:textLayer];

//set text attributes
textLayer.alignmentMode = kCAAlignmentJustified;
textLayer.wrapped = YES;

//choose a font
UIFont *font = [UIFont systemFontOfSize:15];

//choose some text
NSString *text = @"Lorem ipsum dolor sit amet, consectetur adipiscing \ elit. Quisque massa arcu, eleifend vel varius in, facilisis pulvinar \ leo. Nunc quis nunc at mauris pharetra condimentum ut ac neque. Nunc \ elementum, libero ut porttitor dictum, diam odio congue lacus, vel \ fringilla sapien diam at purus. Etiam suscipit pretium nunc sit amet \ lobortis";

//create attributed string
NSMutableAttributedString *string = nil;
string = [[NSMutableAttributedString alloc] initWithString:text];

//convert UIFont to a CTFont
CFStringRef fontName = (__bridge CFStringRef)font.fontName;
CGFloat fontSize = font.pointSize;
CTFontRef fontRef = CTFontCreateWithName(fontName, fontSize, NULL);

//set text attributes
NSDictionary *attribs = @{
(__bridge id)kCTForegroundColorAttributeName:(__bridge id)[UIColor blackColor].CGColor,
(__bridge id)kCTFontAttributeName: (__bridge id)fontRef
};

[string setAttributes:attribs range:NSMakeRange(0, [text length])];
attribs = @{
(__bridge id)kCTForegroundColorAttributeName: (__bridge id)[UIColor redColor].CGColor,
(__bridge id)kCTUnderlineStyleAttributeName: @(kCTUnderlineStyleSingle),
(__bridge id)kCTFontAttributeName: (__bridge id)fontRef
};
[string setAttributes:attribs range:NSMakeRange(6, 5)];

//release the CTFont we created earlier
CFRelease(fontRef);

//set layer text
textLayer.string = string;
}
@end

图6.4

图6.4 用CATextLayer实现一个富文本标签。

行距和字距

有必要提一下的是,由于绘制的实现机制不同(Core Text和WebKit),用CATextLayer渲染和用UILabel渲染出的文本行距和字距也不是不尽相同的。

二者的差异程度(由使用的字体和字符决定)总的来说挺小,但是如果你想正确的显示普通便签和CATextLayer就一定要记住这一点。

UILabel的替代品

我们已经证实了CATextLayerUILabel有着更好的性能表现,同时还有额外的布局选项并且在iOS 5上支持富文本。但是与一般的标签比较而言会更加繁琐一些。如果我们真的在需求一个UILabel的可用替代品,最好是能够在Interface Builder上创建我们的标签,而且尽可能地像一般的视图一样正常工作。

我们应该继承UILabel,然后添加一个子图层CATextLayer并重写显示文本的方法。但是仍然会有由UILabel-drawRect:方法创建的空寄宿图。而且由于CALayer不支持自动缩放和自动布局,子视图并不是主动跟踪视图边界的大小,所以每次视图大小被更改,我们不得不手动更新子图层的边界。

我们真正想要的是一个用CATextLayer作为宿主图层的UILabel子类,这样就可以随着视图自动调整大小而且也没有冗余的寄宿图啦。

就像我们在第一章『图层树』讨论的一样,每一个UIView都是寄宿在一个CALayer的示例上。这个图层是由视图自动创建和管理的,那我们可以用别的图层类型替代它么?一旦被创建,我们就无法代替这个图层了。但是如果我们继承了UIView,那我们就可以重写+layerClass方法使得在创建的时候能返回一个不同的图层子类。UIView会在初始化的时候调用+layerClass方法,然后用它的返回类型来创建宿主图层。

清单6.4 演示了一个UILabel子类LayerLabelCATextLayer绘制它的问题,而不是调用一般的UILabel使用的较慢的-drawRect:方法。LayerLabel示例既可以用代码实现,也可以在Interface Builder实现,只要把普通的标签拖入视图之中,然后设置它的类是LayerLabel就可以了。

清单6.4 使用CATextLayerUILabel子类:LayerLabel

#import "LayerLabel.h"
#import

@implementation LayerLabel
+ (Class)layerClass
{
//this makes our label create a CATextLayer //instead of a regular CALayer for its backing layer
return [CATextLayer class];
}

- (CATextLayer *)textLayer
{
return (CATextLayer *)self.layer;
}

- (void)setUp
{
//set defaults from UILabel settings
self.text = self.text;
self.textColor = self.textColor;
self.font = self.font;

//we should really derive these from the UILabel settings too
//but that's complicated, so for now we'll just hard-code them
[self textLayer].alignmentMode = kCAAlignmentJustified;

[self textLayer].wrapped = YES;
[self.layer display];
}

- (id)initWithFrame:(CGRect)frame
{
//called when creating label programmatically
if (self = [super initWithFrame:frame]) {
[self setUp];
}
return self;
}

- (void)awakeFromNib
{
//called when creating label using Interface Builder
[self setUp];
}

- (void)setText:(NSString *)text
{
super.text = text;
//set layer text
[self textLayer].string = text;
}

- (void)setTextColor:(UIColor *)textColor
{
super.textColor = textColor;
//set layer text color
[self textLayer].foregroundColor = textColor.CGColor;
}

- (void)setFont:(UIFont *)font
{
super.font = font;
//set layer font
CFStringRef fontName = (__bridge CFStringRef)font.fontName;
CGFontRef fontRef = CGFontCreateWithFontName(fontName);
[self textLayer].font = fontRef;
[self textLayer].fontSize = font.pointSize;

CGFontRelease(fontRef);
}
@end

如果你运行代码,你会发现文本并没有像素化,而我们也没有设置contentsScale属性。把CATextLayer作为宿主图层的另一好处就是视图自动设置了contentsScale属性。

在这个简单的例子中,我们只是实现了UILabel的一部分风格和布局属性,不过稍微再改进一下我们就可以创建一个支持UILabel所有功能甚至更多功能的LayerLabel类(你可以在一些线上的开源项目中找到)。

如果你打算支持iOS 6及以上,基于CATextLayer的标签可能就有有些局限性。但是总得来说,如果想在app里面充分利用CALayer子类,用+layerClass来创建基于不同图层的视图是一个简单可复用的方法。

收起阅读 »

iOS 专用图层 一

专用图层复杂的组织都是专门化的Catharine R. Stimpson到目前为止,我们已经探讨过CALayer类了,同时我们也了解到了一些非常有用的绘图和动画功能。但是Core Animation图层不仅仅能作用于图片和颜色而已。本章就会学习其他的一些图层类...
继续阅读 »

专用图层

复杂的组织都是专门化的

Catharine R. Stimpson

到目前为止,我们已经探讨过CALayer类了,同时我们也了解到了一些非常有用的绘图和动画功能。但是Core Animation图层不仅仅能作用于图片和颜色而已。本章就会学习其他的一些图层类,进一步扩展使用Core Animation绘图的能力。

6.1 CAShapeLayer

在第四章『视觉效果』我们学习到了不使用图片的情况下用CGPath去构造任意形状的阴影。如果我们能用同样的方式创建相同形状的图层就好了。

CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。你指定诸如颜色和线宽等属性,用CGPath来定义想要绘制的图形,最后CAShapeLayer就自动渲染出来了。当然,你也可以用Core Graphics直接向原始的CALyer的内容中绘制一个路径,相比直下,使用CAShapeLayer有以下一些优点:

  • 渲染快速。CAShapeLayer使用了硬件加速,绘制同一图形会比用Core Graphics快很多。
  • 高效使用内存。一个CAShapeLayer不需要像普通CALayer一样创建一个寄宿图形,所以无论有多大,都不会占用太多的内存。
  • 不会被图层边界剪裁掉。一个CAShapeLayer可以在边界之外绘制。你的图层路径不会像在使用Core Graphics的普通CALayer一样被剪裁掉(如我们在第二章所见)。
  • 不会出现像素化。当你给CAShapeLayer做3D变换时,它不像一个有寄宿图的普通图层一样变得像素化。

创建一个CGPath

CAShapeLayer可以用来绘制所有能够通过CGPath来表示的形状。这个形状不一定要闭合,图层路径也不一定要不可破,事实上你可以在一个图层上绘制好几个不同的形状。你可以控制一些属性比如lineWith(线宽,用点表示单位),lineCap(线条结尾的样子),和lineJoin(线条之间的结合点的样子);但是在图层层面你只有一次机会设置这些属性。如果你想用不同颜色或风格来绘制多个形状,就不得不为每个形状准备一个图层了。

清单6.1 的代码用一个CAShapeLayer渲染一个简单的火柴人。CAShapeLayer属性是CGPathRef类型,但是我们用UIBezierPath帮助类创建了图层路径,这样我们就不用考虑人工释放CGPath了。图6.1是代码运行的结果。虽然还不是很完美,但是总算知道了大意对吧!

清单6.1 用CAShapeLayer绘制一个火柴人

#import "DrawingView.h"
#import

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//create path
UIBezierPath *path = [[UIBezierPath alloc] init];
[path moveToPoint:CGPointMake(175, 100)];

[path addArcWithCenter:CGPointMake(150, 100) radius:25 startAngle:0 endAngle:2*M_PI clockwise:YES];
[path moveToPoint:CGPointMake(150, 125)];
[path addLineToPoint:CGPointMake(150, 175)];
[path addLineToPoint:CGPointMake(125, 225)];
[path moveToPoint:CGPointMake(150, 175)];
[path addLineToPoint:CGPointMake(175, 225)];
[path moveToPoint:CGPointMake(100, 150)];
[path addLineToPoint:CGPointMake(200, 150)];

//create shape layer
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.strokeColor = [UIColor redColor].CGColor;
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.lineWidth = 5;
shapeLayer.lineJoin = kCALineJoinRound;
shapeLayer.lineCap = kCALineCapRound;
shapeLayer.path = path.CGPath;
//add it to our view
[self.containerView.layer addSublayer:shapeLayer];
}
@end

图6.1

图6.1 用CAShapeLayer绘制一个简单的火柴人

圆角

第二章里面提到了CAShapeLayer为创建圆角视图提供了一个方法,就是CALayercornerRadius属性(译者注:其实是在第四章提到的)。虽然使用CAShapeLayer类需要更多的工作,但是它有一个优势就是可以单独指定每个角。

我们创建圆角矩形其实就是人工绘制单独的直线和弧度,但是事实上UIBezierPath有自动绘制圆角矩形的构造方法,下面这段代码绘制了一个有三个圆角一个直角的矩形:

//define path parameters
CGRect rect = CGRectMake(50, 50, 100, 100);
CGSize radii = CGSizeMake(20, 20);
UIRectCorner corners = UIRectCornerTopRight | UIRectCornerBottomRight | UIRectCornerBottomLeft;
//create path
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect byRoundingCorners:corners cornerRadii:radii];

我们可以通过这个图层路径绘制一个既有直角又有圆角的视图。如果我们想依照此图形来剪裁视图内容,我们可以把CAShapeLayer作为视图的宿主图层,而不是添加一个子视图(图层蒙板的详细解释见第四章『视觉效果』)。

收起阅读 »

iOS 变化 三

5.3 固体对象现在你懂得了在3D空间的一些图层布局的基础,我们来试着创建一个固态的3D对象(实际上是一个技术上所谓的空洞对象,但它以固态呈现)。我们用六个独立的视图来构建一个立方体的各个面。在这个例子中,我们用Interface Builder来构建立方体的...
继续阅读 »

5.3 固体对象

现在你懂得了在3D空间的一些图层布局的基础,我们来试着创建一个固态的3D对象(实际上是一个技术上所谓的空洞对象,但它以固态呈现)。我们用六个独立的视图来构建一个立方体的各个面。

在这个例子中,我们用Interface Builder来构建立方体的面(图5.19),我们当然可以用代码来写,但是用Interface Builder的好处是可以方便的在每一个面上添加子视图。记住这些面仅仅是包含视图和控件的普通的用户界面元素,它们完全是我们界面交互的部分,并且当把它折成一个立方体之后也不会改变这个性质。

图5.19

图5.19 用Interface Builder对立方体的六个面进行布局

这些面视图并没有放置在主视图当中,而是松散地排列在根nib文件里面。我们并不关心在这个容器中如何摆放它们的位置,因为后续将会用图层的transform对它们进行重新布局,并且用Interface Builder在容器视图之外摆放他们可以让我们容易看清楚它们的内容,如果把它们一个叠着一个都塞进主视图,将会变得很难看。

我们把一个有颜色的UILabel放置在视图内部,是为了清楚的辨别它们之间的关系,并且UIButton被放置在第三个面视图里面,后面会做简单的解释。

具体把视图组织成立方体的代码见清单5.9,结果见图5.20

清单5.9 创建一个立方体

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, strong) IBOutletCollection(UIView) NSArray *faces;

@end

@implementation ViewController

- (void)addFace:(NSInteger)index withTransform:(CATransform3D)transform
{
//get the face view and add it to the container
UIView *face = self.faces[index];
[self.containerView addSubview:face];
//center the face view within the container
CGSize containerSize = self.containerView.bounds.size;
face.center = CGPointMake(containerSize.width / 2.0, containerSize.height / 2.0);
// apply the transform
face.layer.transform = transform;
}

- (void)viewDidLoad
{
[super viewDidLoad];
//set up the container sublayer transform
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = -1.0 / 500.0;
self.containerView.layer.sublayerTransform = perspective;
//add cube face 1
CATransform3D transform = CATransform3DMakeTranslation(0, 0, 100);
[self addFace:0 withTransform:transform];
//add cube face 2
transform = CATransform3DMakeTranslation(100, 0, 0);
transform = CATransform3DRotate(transform, M_PI_2, 0, 1, 0);
[self addFace:1 withTransform:transform];
//add cube face 3
transform = CATransform3DMakeTranslation(0, -100, 0);
transform = CATransform3DRotate(transform, M_PI_2, 1, 0, 0);
[self addFace:2 withTransform:transform];
//add cube face 4
transform = CATransform3DMakeTranslation(0, 100, 0);
transform = CATransform3DRotate(transform, -M_PI_2, 1, 0, 0);
[self addFace:3 withTransform:transform];
//add cube face 5
transform = CATransform3DMakeTranslation(-100, 0, 0);
transform = CATransform3DRotate(transform, -M_PI_2, 0, 1, 0);
[self addFace:4 withTransform:transform];
//add cube face 6
transform = CATransform3DMakeTranslation(0, 0, -100);
transform = CATransform3DRotate(transform, M_PI, 0, 1, 0);
[self addFace:5 withTransform:transform];
}

@end

图5.20

图5.20 正面朝上的立方体

从这个角度看立方体并不是很明显;看起来只是一个方块,为了更好地欣赏它,我们将更换一个不同的视角

旋转这个立方体将会显得很笨重,因为我们要单独对每个面做旋转。另一个简单的方案是通过调整容器视图的sublayerTransform去旋转照相机

添加如下几行去旋转containerView图层的perspective变换矩阵:

perspective = CATransform3DRotate(perspective, -M_PI_4, 1, 0, 0); 
perspective = CATransform3DRotate(perspective, -M_PI_4, 0, 1, 0);

这就对相机(或者相对相机的整个场景,你也可以这么认为)绕Y轴旋转45度,并且绕X轴旋转45度。现在从另一个角度去观察立方体,就能看出它的真实面貌(图5.21)。

图5.21

图5.21 从一个边角观察的立方体

光亮和阴影

现在它看起来更像是一个立方体没错了,但是对每个面之间的连接还是很难分辨。Core Animation可以用3D显示图层,但是它对光线并没有概念。如果想让立方体看起来更加真实,需要自己做一个阴影效果。你可以通过改变每个面的背景颜色或者直接用带光亮效果的图片来调整。

如果需要动态地创建光线效果,你可以根据每个视图的方向应用不同的alpha值做出半透明的阴影图层,但为了计算阴影图层的不透明度,你需要得到每个面的正太向量(垂直于表面的向量),然后根据一个想象的光源计算出两个向量叉乘结果。叉乘代表了光源和图层之间的角度,从而决定了它有多大程度上的光亮。

清单5.10实现了这样一个结果,我们用GLKit框架来做向量的计算(你需要引入GLKit库来运行代码),每个面的CATransform3D都被转换成GLKMatrix4,然后通过GLKMatrix4GetMatrix3函数得出一个3×3的旋转矩阵。这个旋转矩阵指定了图层的方向,然后可以用它来得到正太向量的值。

结果如图5.22所示,试着调整LIGHT_DIRECTIONAMBIENT_LIGHT的值来切换光线效果

清单5.10 对立方体的表面应用动态的光线效果

#import "ViewController.h" 
#import
#import

#define LIGHT_DIRECTION 0, 1, -0.5
#define AMBIENT_LIGHT 0.5

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, strong) IBOutletCollection(UIView) NSArray *faces;

@end

@implementation ViewController

- (void)applyLightingToFace:(CALayer *)face
{
//add lighting layer
CALayer *layer = [CALayer layer];
layer.frame = face.bounds;
[face addSublayer:layer];
//convert the face transform to matrix
//(GLKMatrix4 has the same structure as CATransform3D)
//译者注:GLKMatrix4和CATransform3D内存结构一致,但坐标类型有长度区别,所以理论上应该做一次float到CGFloat的转换,感谢[@zihuyishi](https://github.com/zihuyishi)同学~
CATransform3D transform = face.transform;
GLKMatrix4 matrix4 = *(GLKMatrix4 *)&transform;
GLKMatrix3 matrix3 = GLKMatrix4GetMatrix3(matrix4);
//get face normal
GLKVector3 normal = GLKVector3Make(0, 0, 1);
normal = GLKMatrix3MultiplyVector3(matrix3, normal);
normal = GLKVector3Normalize(normal);
//get dot product with light direction
GLKVector3 light = GLKVector3Normalize(GLKVector3Make(LIGHT_DIRECTION));
float dotProduct = GLKVector3DotProduct(light, normal);
//set lighting layer opacity
CGFloat shadow = 1 + dotProduct - AMBIENT_LIGHT;
UIColor *color = [UIColor colorWithWhite:0 alpha:shadow];
layer.backgroundColor = color.CGColor;
}

- (void)addFace:(NSInteger)index withTransform:(CATransform3D)transform
{
//get the face view and add it to the container
UIView *face = self.faces[index];
[self.containerView addSubview:face];
//center the face view within the container
CGSize containerSize = self.containerView.bounds.size;
face.center = CGPointMake(containerSize.width / 2.0, containerSize.height / 2.0);
// apply the transform
face.layer.transform = transform;
//apply lighting
[self applyLightingToFace:face.layer];
}

- (void)viewDidLoad
{
[super viewDidLoad];
//set up the container sublayer transform
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = -1.0 / 500.0;
perspective = CATransform3DRotate(perspective, -M_PI_4, 1, 0, 0);
perspective = CATransform3DRotate(perspective, -M_PI_4, 0, 1, 0);
self.containerView.layer.sublayerTransform = perspective;
//add cube face 1
CATransform3D transform = CATransform3DMakeTranslation(0, 0, 100);
[self addFace:0 withTransform:transform];
//add cube face 2
transform = CATransform3DMakeTranslation(100, 0, 0);
transform = CATransform3DRotate(transform, M_PI_2, 0, 1, 0);
[self addFace:1 withTransform:transform];
//add cube face 3
transform = CATransform3DMakeTranslation(0, -100, 0);
transform = CATransform3DRotate(transform, M_PI_2, 1, 0, 0);
[self addFace:2 withTransform:transform];
//add cube face 4
transform = CATransform3DMakeTranslation(0, 100, 0);
transform = CATransform3DRotate(transform, -M_PI_2, 1, 0, 0);
[self addFace:3 withTransform:transform];
//add cube face 5
transform = CATransform3DMakeTranslation(-100, 0, 0);
transform = CATransform3DRotate(transform, -M_PI_2, 0, 1, 0);
[self addFace:4 withTransform:transform];
//add cube face 6
transform = CATransform3DMakeTranslation(0, 0, -100);
transform = CATransform3DRotate(transform, M_PI, 0, 1, 0);
[self addFace:5 withTransform:transform];
}

@end

图5.22

图5.22 动态计算光线效果之后的立方体

点击事件

你应该能注意到现在可以在第三个表面的顶部看见按钮了,点击它,什么都没发生,为什么呢?

这并不是因为iOS在3D场景下正确地处理响应事件,实际上是可以做到的。问题在于视图顺序。在第三章中我们简要提到过,点击事件的处理由视图在父视图中的顺序决定的,并不是3D空间中的Z轴顺序。当给立方体添加视图的时候,我们实际上是按照一个顺序添加,所以按照视图/图层顺序来说,4,5,6在3的前面。

即使我们看不见4,5,6的表面(因为被1,2,3遮住了),iOS在事件响应上仍然保持之前的顺序。当试图点击表面3上的按钮,表面4,5,6截断了点击事件(取决于点击的位置),这就和普通的2D布局在按钮上覆盖物体一样。

你也许认为把doubleSided设置成NO可以解决这个问题,因为它不再渲染视图后面的内容,但实际上并不起作用。因为背对相机而隐藏的视图仍然会响应点击事件(这和通过设置hidden属性或者设置alpha为0而隐藏的视图不同,那两种方式将不会响应事件)。所以即使禁止了双面渲染仍然不能解决这个问题(虽然由于性能问题,还是需要把它设置成NO)。

这里有几种正确的方案:把除了表面3的其他视图userInteractionEnabled属性都设置成NO来禁止事件传递。或者简单通过代码把视图3覆盖在视图6上。无论怎样都可以点击按钮了(图5.23)。

图5.23

图5.23 背景视图不再阻碍按钮,我们可以点击它了

总结

这一章涉及了一些2D和3D的变换。你学习了一些矩阵计算的基础,以及如何用Core Animation创建3D场景。你看到了图层背后到底是如何呈现的,并且知道了不能把扁平的图片做成真实的立体效果,最后我们用demo说明了触摸事件的处理,视图中图层添加的层级顺序会比屏幕上显示的顺序更有意义。

第六章我们会研究一些Core Animation提供不同功能的具体的CALayer子类。

收起阅读 »

iOS 变化 二

5.2 3D变换CG的前缀告诉我们,CGAffineTransform类型属于Core Graphics框架,Core Graphics实际上是一个严格意义上的2D绘图API,并且CGAffineTransform仅仅对2D变换有效。在第三章中,我们提到了zP...
继续阅读 »

5.2 3D变换

CG的前缀告诉我们,CGAffineTransform类型属于Core Graphics框架,Core Graphics实际上是一个严格意义上的2D绘图API,并且CGAffineTransform仅仅对2D变换有效。

在第三章中,我们提到了zPosition属性,可以用来让图层靠近或者远离相机(用户视角),transform属性(CATransform3D类型)可以真正做到这点,即让图层在3D空间内移动或者旋转。

CGAffineTransform类似,CATransform3D也是一个矩阵,但是和2x3的矩阵不同,CATransform3D是一个可以在3维空间内做变换的4x4的矩阵(图5.6)。

图5.6

图5.6 对一个3D像素点做CATransform3D矩阵变换

CGAffineTransform矩阵类似,Core Animation提供了一系列的方法用来创建和组合CATransform3D类型的矩阵,和Core Graphics的函数类似,但是3D的平移和旋转多处了一个z参数,并且旋转函数除了angle之外多出了x,y,z三个参数,分别决定了每个坐标轴方向上的旋转:

CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z)
CATransform3DMakeScale(CGFloat sx, CGFloat sy, CGFloat sz)
CATransform3DMakeTranslation(Gloat tx, CGFloat ty, CGFloat tz)

你应该对X轴和Y轴比较熟悉了,分别以右和下为正方向(回忆第三章,这是iOS上的标准结构,在Mac OS,Y轴朝上为正方向),Z轴和这两个轴分别垂直,指向视角外为正方向(图5.7)。

图5.7

图5.7 X,Y,Z轴,以及围绕它们旋转的方向

由图所见,绕Z轴的旋转等同于之前二维空间的仿射旋转,但是绕X轴和Y轴的旋转就突破了屏幕的二维空间,并且在用户视角看来发生了倾斜。

举个例子:清单5.4的代码使用了CATransform3DMakeRotation对视图内的图层绕Y轴做了45度角的旋转,我们可以把视图向右倾斜,这样会看得更清晰。

结果见图5.8,但并不像我们期待的那样。

清单5.4 绕Y轴旋转图层

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees along the Y axis
CATransform3D transform = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);
self.layerView.layer.transform = transform;
}

@end

图5.8

图5.8 绕y轴旋转45度的视图

看起来图层并没有被旋转,而是仅仅在水平方向上的一个压缩,是哪里出了问题呢?

其实完全没错,视图看起来更窄实际上是因为我们在用一个斜向的视角看它,而不是透视

透视投影

在真实世界中,当物体远离我们的时候,由于视角的原因看起来会变小,理论上说远离我们的视图的边要比靠近视角的边跟短,但实际上并没有发生,而我们当前的视角是等距离的,也就是在3D变换中任然保持平行,和之前提到的仿射变换类似。

在等距投影中,远处的物体和近处的物体保持同样的缩放比例,这种投影也有它自己的用处(例如建筑绘图,颠倒,和伪3D视频),但当前我们并不需要。

为了做一些修正,我们需要引入投影变换(又称作z变换)来对除了旋转之外的变换矩阵做一些修改,Core Animation并没有给我们提供设置透视变换的函数,因此我们需要手动修改矩阵值,幸运的是,很简单:

CATransform3D的透视效果通过一个矩阵中一个很简单的元素来控制:m34m34(图5.9)用于按比例缩放X和Y的值来计算到底要离视角多远。

图5.9

图5.9 CATransform3Dm34元素,用来做透视

m34的默认值是0,我们可以通过设置m34为-1.0 / d来应用透视效果,d代表了想象中视角相机和屏幕之间的距离,以像素为单位,那应该如何计算这个距离呢?实际上并不需要,大概估算一个就好了。

因为视角相机实际上并不存在,所以可以根据屏幕上的显示效果自由决定它的防止的位置。通常500-1000就已经很好了,但对于特定的图层有时候更小后者更大的值会看起来更舒服,减少距离的值会增强透视效果,所以一个非常微小的值会让它看起来更加失真,然而一个非常大的值会让它基本失去透视效果,对视图应用透视的代码见清单5.5,结果见图5.10。

清单5.5 对变换应用透视效果

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CATransform3D transform = CATransform3DIdentity;
//apply perspective
transform.m34 = - 1.0 / 500.0;
//rotate by 45 degrees along the Y axis
transform = CATransform3DRotate(transform, M_PI_4, 0, 1, 0);
//apply to layer
self.layerView.layer.transform = transform;
}

@end

图5.10

图5.10 应用透视效果之后再次对图层做旋转

灭点

当在透视角度绘图的时候,远离相机视角的物体将会变小变远,当远离到一个极限距离,它们可能就缩成了一个点,于是所有的物体最后都汇聚消失在同一个点。

在现实中,这个点通常是视图的中心(图5.11),于是为了在应用中创建拟真效果的透视,这个点应该聚在屏幕中点,或者至少是包含所有3D对象的视图中点。

图5.11

图5.11 灭点

Core Animation定义了这个点位于变换图层的anchorPoint(通常位于图层中心,但也有例外,见第三章)。这就是说,当图层发生变换时,这个点永远位于图层变换之前anchorPoint的位置。

当改变一个图层的position,你也改变了它的灭点,做3D变换的时候要时刻记住这一点,当你视图通过调整m34来让它更加有3D效果,应该首先把它放置于屏幕中央,然后通过平移来把它移动到指定位置(而不是直接改变它的position),这样所有的3D图层都共享一个灭点。

sublayerTransform属性

如果有多个视图或者图层,每个都做3D变换,那就需要分别设置相同的m34值,并且确保在变换之前都在屏幕中央共享同一个position,如果用一个函数封装这些操作的确会更加方便,但仍然有限制(例如,你不能在Interface Builder中摆放视图),这里有一个更好的方法。

CALayer有一个属性叫做sublayerTransform。它也是CATransform3D类型,但和对一个图层的变换不同,它影响到所有的子图层。这意味着你可以一次性对包含这些图层的容器做变换,于是所有的子图层都自动继承了这个变换方法。

相较而言,通过在一个地方设置透视变换会很方便,同时它会带来另一个显著的优势:灭点被设置在容器图层的中点,从而不需要再对子图层分别设置了。这意味着你可以随意使用positionframe来放置子图层,而不需要把它们放置在屏幕中点,然后为了保证统一的灭点用变换来做平移。

我们来用一个demo举例说明。这里用Interface Builder并排放置两个视图(图5.12),然后通过设置它们容器视图的透视变换,我们可以保证它们有相同的透视和灭点,代码见清单5.6,结果见图5.13。

图5.12

图5.12 在一个视图容器内并排放置两个视图

清单5.6 应用sublayerTransform

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, weak) IBOutlet UIView *layerView1;
@property (nonatomic, weak) IBOutlet UIView *layerView2;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//apply perspective transform to container
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = - 1.0 / 500.0;
self.containerView.layer.sublayerTransform = perspective;
//rotate layerView1 by 45 degrees along the Y axis
CATransform3D transform1 = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);
self.layerView1.layer.transform = transform1;
//rotate layerView2 by 45 degrees along the Y axis
CATransform3D transform2 = CATransform3DMakeRotation(-M_PI_4, 0, 1, 0);
self.layerView2.layer.transform = transform2;
}

图5.13

图5.13 通过相同的透视效果分别对视图做变换

背面

我们既然可以在3D场景下旋转图层,那么也可以从背面去观察它。如果我们在清单5.4中把角度修改为M_PI(180度)而不是当前的M_PI_4(45度),那么将会把图层完全旋转一个半圈,于是完全背对了相机视角。

那么从背部看图层是什么样的呢,见图5.14

图5.14

图5.14 视图的背面,一个镜像对称的图片

如你所见,图层是双面绘制的,反面显示的是正面的一个镜像图片。

但这并不是一个很好的特性,因为如果图层包含文本或者其他控件,那用户看到这些内容的镜像图片当然会感到困惑。另外也有可能造成资源的浪费:想象用这些图层形成一个不透明的固态立方体,既然永远都看不见这些图层的背面,那为什么浪费GPU来绘制它们呢?

CALayer有一个叫做doubleSided的属性来控制图层的背面是否要被绘制。这是一个BOOL类型,默认为YES,如果设置为NO,那么当图层正面从相机视角消失的时候,它将不会被绘制。

扁平化图层

如果对包含已经做过变换的图层的图层做反方向的变换将会发什么什么呢?是不是有点困惑?见图5.15

图5.15

图5.15 反方向变换的嵌套图层

注意做了-45度旋转的内部图层是怎样抵消旋转45度的图层,从而恢复正常状态的。

如果内部图层相对外部图层做了相反的变换(这里是绕Z轴的旋转),那么按照逻辑这两个变换将被相互抵消。

验证一下,相应代码见清单5.7,结果见5.16

清单5.7 绕Z轴做相反的旋转变换

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *outerView;
@property (nonatomic, weak) IBOutlet UIView *innerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DMakeRotation(M_PI_4, 0, 0, 1);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DMakeRotation(-M_PI_4, 0, 0, 1);
self.innerView.layer.transform = inner;
}

@end

图5.16

图5.16 旋转后的视图

运行结果和我们预期的一致。现在在3D情况下再试一次。修改代码,让内外两个视图绕Y轴旋转而不是Z轴,再加上透视效果,以便我们观察。注意不能用sublayerTransform属性,因为内部的图层并不直接是容器图层的子图层,所以这里分别对图层设置透视变换(清单5.8)。

清单5.8 绕Y轴相反的旋转变换

- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DIdentity;
outer.m34 = -1.0 / 500.0;
outer = CATransform3DRotate(outer, M_PI_4, 0, 1, 0);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DIdentity;
inner.m34 = -1.0 / 500.0;
inner = CATransform3DRotate(inner, -M_PI_4, 0, 1, 0);
self.innerView.layer.transform = inner;
}

预期的效果应该如图5.17所示。

图5.17

图5.17 绕Y轴做相反旋转的预期结果。

但其实这并不是我们所看到的,相反,我们看到的结果如图5.18所示。发什么了什么呢?内部的图层仍然向左侧旋转,并且发生了扭曲,但按道理说它应该保持正面朝上,并且显示正常的方块。

这是由于尽管Core Animation图层存在于3D空间之内,但它们并不都存在同一个3D空间。每个图层的3D场景其实是扁平化的,当你从正面观察一个图层,看到的实际上由子图层创建的想象出来的3D场景,但当你倾斜这个图层,你会发现实际上这个3D场景仅仅是被绘制在图层的表面。

图5.18

图5.18 绕Y轴做相反旋转的真实结果

类似的,当你在玩一个3D游戏,实际上仅仅是把屏幕做了一次倾斜,或许在游戏中可以看见有一面墙在你面前,但是倾斜屏幕并不能够看见墙里面的东西。所有场景里面绘制的东西并不会随着你观察它的角度改变而发生变化;图层也是同样的道理。

这使得用Core Animation创建非常复杂的3D场景变得十分困难。你不能够使用图层树去创建一个3D结构的层级关系--在相同场景下的任何3D表面必须和同样的图层保持一致,这是因为每个的父视图都把它的子视图扁平化了。

至少当你用正常的CALayer的时候是这样,CALayer有一个叫做CATransformLayer的子类来解决这个问题。具体在第六章“特殊的图层”中将会具体讨论。

收起阅读 »

Crash 防护系统 -- KVO 防护

通过本文,您将了解到:KVO Crash 的主要原因KVO 防止 Crash 的常见方案我的 KVO 防护实现测试 KVO 防护效果1. KVO Crash 的常见原因KVO(Key Value Observing) 翻译过来就是键值对观察,是 iO...
继续阅读 »

通过本文,您将了解到:

  1. KVO Crash 的主要原因
  2. KVO 防止 Crash 的常见方案
  3. 我的 KVO 防护实现
  4. 测试 KVO 防护效果

1. KVO Crash 的常见原因

KVO(Key Value Observing) 翻译过来就是键值对观察,是 iOS 观察者模式的一种实现。KVO 允许一个对象监听另一个对象特定属性的改变,并在改变时接收到事件。但是 KVO API 的设计,我个人觉得不是很合理。被观察者需要做的工作太多,日常使用时稍不注意就会导致崩溃。

KVO 日常使用造成崩溃的原因通常有以下几个:

  1. KVO 添加次数和移除次数不匹配:
    • 移除了未注册的观察者,导致崩溃。
    • 重复移除多次,移除次数多于添加次数,导致崩溃。
    • 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
  2. 被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO,导致崩溃。
    例如:被观察者是局部变量的情况(iOS 10 及之前会崩溃)。
  3. 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 方法,导致崩溃。
  4. 添加或者移除时 keypath == nil,导致崩溃。

2. KVO 防止 Crash 常见方案

为了避免上面提到的使用 KVO 造成崩溃的问题,于是出现了很多关于 KVO 的第三方库,比如最出名的就是 FaceBook 开源的第三方库 facebook / KVOController

FBKVOController 对 KVO 机制进行了额外的一层封装,框架不但可以自动帮我们移除观察者,还提供了 block 或者 selector 的方式供我们进行观察处理。不可否认的是,FBKVOController 为我们的开发提供了很大的便利性。但是相对而言,这种方式对项目代码的侵入性比较大,必须考编码规范来强制约束团队人员使用这种方式。

那么有没有一种对项目代码侵入性小,同时还能有效防护 KVO 崩溃的防护机制呢?

网上有很多类似的方案可以参考一下。

方案一:大白健康系统 — iOS APP 运行时 Crash 自动修复系统

  1. 首先为 NSObject 建立一个分类,利用 Method Swizzling,实现自定义的 BMP_addObserver:forKeyPath:options:context:BMP_removeObserver:forKeyPath:BMP_removeObserver:forKeyPath:context:BMPKVO_dealloc方法,用来替换系统原生的添加移除观察者方法的实现。
  2. 然后在观察者和被观察者之间建立一个 KVODelegate 对象,两者之间通过 KVODelegate 对象 建立联系。然后在添加和移除操作时,将 KVO 的相关信息例如 observerkeyPathoptionscontext保存为 KVOInfo 对象,并添加到 KVODelegate 对象 中对应 的 关系哈希表 中,对应原有的添加观察者。
    关系哈希表的数据结构:{keypath : [KVOInfo 对象1, KVOInfo 对象2, ... ]}
  3. 在添加和移除操作的时候,利用 KVODelegate 对象 做转发,把真正的观察者变为 KVODelegate 对象,而当被观察者的特定属性发生了改变,再由 KVODelegate 对象 分发到原有的观察者上。

那么,BayMax 系统是如何避免 KVO 崩溃的呢?

  1. 添加观察者时:通过关系哈希表判断是否重复添加,只添加一次。

  2. 移除观察者时:通过关系哈希表是否已经进行过移除操作,避免多次移除。

  3. 观察键值改变时:同样通过关系哈希表判断,将改变操作分发到原有的观察者上。

    另外,为了避免被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO 导致崩溃。BayMax 系统还利用 Method Swizzling 实现了自定义的 dealloc,在系统 dealloc 调用之前,将多余的观察者移除掉。

方案二: ValiantCat / XXShield(第三方框架)

XXShield 实现方案和 BayMax 系统类似。也是利用一个 Proxy 对象用来做转发, 真正的观察者是 Proxy,被观察者出现了通知信息,由 Proxy 做分发。不过不同点是 Proxy 里面保存的内容没有前者多。只保存了 _observed(被观察者) 和关系哈希表,这个关系哈希表中只维护了 keyPath 和 observer 的关系。

关系哈希表的数据结构:{keypath : [observer1, observer2 , ...](NSHashTable)} 。

XXShield 在 dealloc 中也做了类似将多余观察者移除掉的操作,是通过关系数据结构和 _observed ,然后调用原生移除观察者操作实现的。

方案三: JackLee18 / JKCrashProtect(第三方框架)

JKCrashProtect 相对于前两个方案来讲,看上去更加的简洁明了。他的不同点在于没有使用 delegate。而是直接在分类中建立了一个关系哈希表,用来保存 {keypath : [observer1, observer2 , ...](NSHashTable)} 的关系。

添加的时候,如果关系哈希表中与 keyPath 对应的已经有了相关的观察者,就不再进行添加。同样移除观察者的时候,也在哈希表中进行查找,如果存在 observer、keyPath 的信息,就移除掉,否则就不进行移除操作。

不过,这个框架并没有对被观察者在 dealloc 时仍然注册着 KVO ,造成崩溃的情况进行处理。


3. 我的 KVO 防护实现

参考了这几个方法的实现后,分别实现了一下之后,最终还是选择了 方案一、方案二 这两种方案的实现思路。

  1. 我使用了 YSCKVOProxy 对象,在 YSCKVOProxy 对象 中使用 {keypath : [observer1, observer2 , ...](NSHashTable)} 结构的 关系哈希表 进行 observerkeyPath 之间的维护。
  2. 然后利用 YSCKVOProxy 对象 对添加、移除、观察方法进行分发处理。
  3. 在分类中自定义了 dealloc 的实现,移除了多余的观察者。
  • 代码如下所示:
#import "NSObject+KVODefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

// 判断是否是系统类
static inline BOOL IsSystemClass(Class cls){
BOOL isSystem = NO;
NSString *className = NSStringFromClass(cls);
if ([className hasPrefix:@"NS"] || [className hasPrefix:@"__NS"] || [className hasPrefix:@"OS_xpc"]) {
isSystem = YES;
return isSystem;
}
NSBundle *mainBundle = [NSBundle bundleForClass:cls];
if (mainBundle == [NSBundle mainBundle]) {
isSystem = NO;
}else{
isSystem = YES;
}
return isSystem;
}


#pragma mark - YSCKVOProxy 相关

@interface YSCKVOProxy : NSObject

// 获取所有被观察的 keyPaths
- (NSArray *)getAllKeyPaths;

@end

@implementation YSCKVOProxy
{
// 关系数据表结构:{keypath : [observer1, observer2 , ...](NSHashTable)}
@private
NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *_kvoInfoMap;
}

- (instancetype)init {
self = [super init];
if (self) {
_kvoInfoMap = [NSMutableDictionary dictionary];
}
return self;
}

// 添加 KVO 信息操作, 添加成功返回 YES
- (BOOL)addInfoToMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {

@synchronized (self) {
if (!observer || !keyPath ||
([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}

NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];
if (info.count == 0) {
info = [[NSHashTable alloc] initWithOptions:(NSPointerFunctionsWeakMemory) capacity:0];
[info addObject:observer];

_kvoInfoMap[keyPath] = info;

return YES;
}

if (![info containsObject:observer]) {
[info addObject:observer];
}

return NO;
}
}

// 移除 KVO 信息操作, 添加成功返回 YES
- (BOOL)removeInfoInMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath {

@synchronized (self) {
if (!observer || !keyPath ||
([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}

NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];

if (info.count == 0) {
return NO;
}

[info removeObject:observer];

if (info.count == 0) {
[_kvoInfoMap removeObjectForKey:keyPath];

return YES;
}

return NO;
}
}

// 添加 KVO 信息操作, 添加成功返回 YES
- (BOOL)removeInfoInMapWithObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context {
@synchronized (self) {
if (!observer || !keyPath ||
([keyPath isKindOfClass:[NSString class]] && keyPath.length <= 0)) {
return NO;
}

NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];

if (info.count == 0) {
return NO;
}

[info removeObject:observer];

if (info.count == 0) {
[_kvoInfoMap removeObjectForKey:keyPath];

return YES;
}

return NO;
}
}

// 实际观察者 yscKVOProxy 进行监听,并分发
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)context {

NSHashTable<NSObject *> *info = _kvoInfoMap[keyPath];

for (NSObject *observer in info) {
@try {
[observer observeValueForKeyPath:keyPath ofObject:object change:change context:context];
} @catch (NSException *exception) {
NSString *reason = [NSString stringWithFormat:@"KVO Warning : %@",[exception description]];
NSLog(@"%@",reason);
}
}
}

// 获取所有被观察的 keyPaths
- (NSArray *)getAllKeyPaths {
NSArray <NSString *>*keyPaths = _kvoInfoMap.allKeys;
return keyPaths;
}

@end


#pragma mark - NSObject+KVODefender 分类

@implementation NSObject (KVODefender)

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

// 拦截 `addObserver:forKeyPath:options:context:` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(addObserver:forKeyPath:options:context:)
withMethod: @selector(ysc_addObserver:forKeyPath:options:context:)
withClass: [NSObject class]];

// 拦截 `removeObserver:forKeyPath:` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(removeObserver:forKeyPath:)
withMethod: @selector(ysc_removeObserver:forKeyPath:)
withClass: [NSObject class]];

// 拦截 `removeObserver:forKeyPath:context:` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingInstanceMethod: @selector(removeObserver:forKeyPath:context:)
withMethod: @selector(ysc_removeObserver:forKeyPath:context:)
withClass: [NSObject class]];

// 拦截 `dealloc` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingInstanceMethod: NSSelectorFromString(@"dealloc")
withMethod: @selector(ysc_kvodealloc)
withClass: [NSObject class]];
});
}

static void *YSCKVOProxyKey = &YSCKVOProxyKey;
static NSString *const KVODefenderValue = @"YSC_KVODefender";
static void *KVODefenderKey = &KVODefenderKey;

// YSCKVOProxy setter 方法
- (void)setYscKVOProxy:(YSCKVOProxy *)yscKVOProxy {
objc_setAssociatedObject(self, YSCKVOProxyKey, yscKVOProxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

// YSCKVOProxy getter 方法
- (YSCKVOProxy *)yscKVOProxy {
id yscKVOProxy = objc_getAssociatedObject(self, YSCKVOProxyKey);
if (yscKVOProxy == nil) {
yscKVOProxy = [[YSCKVOProxy alloc] init];
self.yscKVOProxy = yscKVOProxy;
}
return yscKVOProxy;
}

// 自定义 addObserver:forKeyPath:options:context: 实现方法
- (void)ysc_addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {

if (!IsSystemClass(self.class)) {
objc_setAssociatedObject(self, KVODefenderKey, KVODefenderValue, OBJC_ASSOCIATION_RETAIN);
if ([self.yscKVOProxy addInfoToMapWithObserver:observer forKeyPath:keyPath options:options context:context]) {
// 如果添加 KVO 信息操作成功,则调用系统添加方法
[self ysc_addObserver:self.yscKVOProxy forKeyPath:keyPath options:options context:context];
} else {
// 添加 KVO 信息操作失败:重复添加
NSString *className = (NSStringFromClass(self.class) == nil) ? @"" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Repeated additions to the observer:%@ for the key path:'%@' from %@",
observer, keyPath, className];
NSLog(@"%@",reason);
}
} else {
[self ysc_addObserver:observer forKeyPath:keyPath options:options context:context];
}
}

// 自定义 removeObserver:forKeyPath:context: 实现方法
- (void)ysc_removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context {

if (!IsSystemClass(self.class)) {
if ([self.yscKVOProxy removeInfoInMapWithObserver:observer forKeyPath:keyPath context:context]) {
// 如果移除 KVO 信息操作成功,则调用系统移除方法
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath context:context];
} else {
// 移除 KVO 信息操作失败:移除了未注册的观察者
NSString *className = NSStringFromClass(self.class) == nil ? @"" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Cannot remove an observer %@ for the key path '%@' from %@ , because it is not registered as an observer", observer, keyPath, className];
NSLog(@"%@",reason);
}
} else {
[self ysc_removeObserver:observer forKeyPath:keyPath context:context];
}
}

// 自定义 removeObserver:forKeyPath: 实现方法
- (void)ysc_removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath {

if (!IsSystemClass(self.class)) {
if ([self.yscKVOProxy removeInfoInMapWithObserver:observer forKeyPath:keyPath]) {
// 如果移除 KVO 信息操作成功,则调用系统移除方法
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath];
} else {
// 移除 KVO 信息操作失败:移除了未注册的观察者
NSString *className = NSStringFromClass(self.class) == nil ? @"" : NSStringFromClass(self.class);
NSString *reason = [NSString stringWithFormat:@"KVO Warning : Cannot remove an observer %@ for the key path '%@' from %@ , because it is not registered as an observer", observer, keyPath, className];
NSLog(@"%@",reason);
}
} else {
[self ysc_removeObserver:observer forKeyPath:keyPath];
}

}

// 自定义 dealloc 实现方法
- (void)ysc_kvodealloc {
@autoreleasepool {
if (!IsSystemClass(self.class)) {
NSString *value = (NSString *)objc_getAssociatedObject(self, KVODefenderKey);
if ([value isEqualToString:KVODefenderValue]) {
NSArray *keyPaths = [self.yscKVOProxy getAllKeyPaths];
// 被观察者在 dealloc 时仍然注册着 KVO
if (keyPaths.count > 0) {
NSString *reason = [NSString stringWithFormat:@"KVO Warning : An instance %@ was deallocated while key value observers were still registered with it. The Keypaths is:'%@'", self, [keyPaths componentsJoinedByString:@","]];
NSLog(@"%@",reason);
}

// 移除多余的观察者
for (NSString *keyPath in keyPaths) {
[self ysc_removeObserver:self.yscKVOProxy forKeyPath:keyPath];
}
}
}
}


[self ysc_kvodealloc];
}

@end

4. 测试 KVO 防护效果

这里提供一下相关崩溃的测试代码:


/********************* KVOCrashObject.h 文件 *********************/
#import <Foundation/Foundation.h>

@interface KVOCrashObject : NSObject

@property (nonatomic, copy) NSString *name;

@end

/********************* KVOCrashObject.m 文件 *********************/
#import "KVOCrashObject.h"

@implementation KVOCrashObject

@end

/********************* ViewController.m 文件 *********************/
#import "ViewController.h"
#import "KVOCrashObject.h"

@interface ViewController ()

@property (nonatomic, strong) KVOCrashObject *objc;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.

self.objc = [[KVOCrashObject alloc] init];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

// 1.1 移除了未注册的观察者,导致崩溃
[self testKVOCrash11];

// 1.2 重复移除多次,移除次数多于添加次数,导致崩溃
// [self testKVOCrash12];

// 1.3 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
// [self testKVOCrash13];

// 2. 被观察者 dealloc 时仍然注册着 KVO,导致崩溃
// [self testKVOCrash2];

// 3. 观察者没有实现 -observeValueForKeyPath:ofObject:change:context:导致崩溃
// [self testKVOCrash3];

// 4. 添加或者移除时 keypath == nil,导致崩溃。
// [self testKVOCrash4];
}

/**
1.1 移除了未注册的观察者,导致崩溃
*/

- (void)testKVOCrash11 {
// 崩溃日志:Cannot remove an observer XXX for the key path "xxx" from XXX because it is not registered as an observer.
[self.objc removeObserver:self forKeyPath:@"name"];
}

/**
1.2 重复移除多次,移除次数多于添加次数,导致崩溃
*/

- (void)testKVOCrash12 {
// 崩溃日志:Cannot remove an observer XXX for the key path "xxx" from XXX because it is not registered as an observer.
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
self.objc.name = @"0";
[self.objc removeObserver:self forKeyPath:@"name"];
[self.objc removeObserver:self forKeyPath:@"name"];
}

/**
1.3 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
*/

- (void)testKVOCrash13 {
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
[self.objc addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
self.objc.name = @"0";
}

/**
2. 被观察者 dealloc 时仍然注册着 KVO,导致崩溃
*/

- (void)testKVOCrash2 {
// 崩溃日志:An instance xxx of class xxx was deallocated while key value observers were still registered with it.
// iOS 10 及以下会导致崩溃,iOS 11 之后就不会崩溃了
KVOCrashObject *obj = [[KVOCrashObject alloc] init];
[obj addObserver: self
forKeyPath: @"name"
options: NSKeyValueObservingOptionNew
context: nil];
}

/**
3. 观察者没有实现 -observeValueForKeyPath:ofObject:change:context:导致崩溃
*/

- (void)testKVOCrash3 {
// 崩溃日志:An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.
KVOCrashObject *obj = [[KVOCrashObject alloc] init];

[self addObserver: obj
forKeyPath: @"title"
options: NSKeyValueObservingOptionNew
context: nil];

self.title = @"111";
}

/**
4. 添加或者移除时 keypath == nil,导致崩溃。
*/

- (void)testKVOCrash4 {
// 崩溃日志: -[__NSCFConstantString characterAtIndex:]: Range or index out of bounds
KVOCrashObject *obj = [[KVOCrashObject alloc] init];

[self addObserver: obj
forKeyPath: @""
options: NSKeyValueObservingOptionNew
context: nil];

// [self removeObserver:obj forKeyPath:@""];
}


- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary*)change context:(void *)context {

NSLog(@"object = %@, keyPath = %@", object, keyPath);
}

@end

可以将示例项目 NSObject+KVODefender.m 中的 + (void)load; 方法注释掉或打开进行防护前后的测试。

经测试可以发现,成功的拦截了这几种因为 KVO 使用不当导致的崩溃。



作者:NJKNJK
链接:https://www.jianshu.com/p/0d67bb7b96de


收起阅读 »

『Crash 防护系统』 一 Unrecognized Selector

这个系列将会介绍如何设计一套 APP Crash 防护系统。这套系统采用 AOP(面向切面编程)的设计思想,利用 Objective-C语言的运行时机制,在不侵入原有项目代码的基础之上,通过在 APP 运行时阶段对崩溃因素的的拦截和处理,使得 APP 能够持续...
继续阅读 »
这个系列将会介绍如何设计一套 APP Crash 防护系统。这套系统采用 AOP(面向切面编程)的设计思想,利用 Objective-C语言的运行时机制,在不侵入原有项目代码的基础之上,通过在 APP 运行时阶段对崩溃因素的的拦截和处理,使得 APP 能够持续稳定正常的运行。

通过本文,您将了解到:

  1. Crash 防护系统开篇
  2. 防护原理简介和常见 Crash
  3. Method Swizzling 方法的封装
  4. Unrecognized Selector 防护
    4.1 unrecognized selector sent to instance(找不到对象方法的实现)
    4.2 unrecognized selector sent to class(找不到类方法实现)

1. Crash 防护系统开篇

APP 的崩溃问题,一直以来都是开发过程中重中之重的问题。日常开发阶段的崩溃,发现后还能够立即处理。但是一旦发布上架的版本出现问题,就需要紧急加班修复 BUG,再更新上架新版本了。在这个过程中, 说不定会因为崩溃而导致关键业务中断、用户存留率下降、品牌口碑变差、生命周期价值下降等,最终导致流失用户,影响到公司的发展。

当然,避免崩溃问题的最好办法就是不产生崩溃。在开发的过程中就要尽可能地保证程序的健壮性。但是,人又不是机器,不可能不犯错。不可能存在没有 BUG 的程序。但是如果能够利用一些语言机制和系统方法,设计一套防护系统,使之能够有效的降低 APP 的崩溃率,那么不仅 APP 的稳定性得到了保障,而且最重要的是可以减少不必要的加班。

这套 Crash 防护系统被命名为:『YSCDefender(防卫者)』。Defender 也是路虎旗下最硬派的越野车系。在电影《Tomb Raider》里面,由 Angelina Jolie 饰演的英国女探险家 Lara Croft,所驾驶的就是一台 Defender。Defender 也是我比较喜欢的车之一。

不过呢,这不重要。。。我就是为这个项目起了个花里胡哨的名字,并给这个名字赋予了一些无聊的意义。。。


2. 防护原理简介和常见 Crash

Objective-C 语言是一门动态语言,我们可以利用 Objective-C 语言的 Runtime 运行时机制,对需要 Hook 的类添加 Category(分类),在各个分类的 +(void)load; 中通过 Method Swizzling 拦截容易造成崩溃的系统方法,将系统原有方法与添加的防护方法的 selector(方法选择器) 与 IMP(函数实现指针)进行对调。然后在替换方法中添加防护操作,从而达到避免以及修复崩溃的目的。

通过 Runtime 机制可以避免的常见 Crash :

  1. unrecognized selector sent to instance(找不到对象方法的实现)
  2. unrecognized selector sent to class(找不到类方法实现)
  3. KVO Crash
  4. KVC Crash
  5. NSNotification Crash
  6. NSTimer Crash
  7. Container Crash(集合类操作造成的崩溃,例如数组越界,插入 nil 等)
  8. NSString Crash (字符串类操作造成的崩溃)
  9. Bad Access Crash (野指针)
  10. Threading Crash (非主线程刷 UI)
  11. NSNull Crash

这一篇我们先来讲解下 unrecognized selector sent to instance(找不到对象方法的实现) 和 unrecognized selector sent to class(找不到类方法实现) 造成的崩溃问题。


3. Method Swizzling 方法的封装

由于这几种常见 Crash 的防护都需要用到 Method Swizzling 技术。所以我们可以为 NSObject 新建一个分类,将 Method Swizzling 相关的方法封装起来。

/********************* NSObject+MethodSwizzling.h 文件 *********************/

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSObject (MethodSwizzling)

/** 交换两个类方法的实现
* @param originalSelector 原始方法的 SEL
* @param swizzledSelector 交换方法的 SEL
* @param targetClass 类
*/

+ (void)yscDefenderSwizzlingClassMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass;

/** 交换两个对象方法的实现
* @param originalSelector 原始方法的 SEL
* @param swizzledSelector 交换方法的 SEL
* @param targetClass 类
*/

+ (void)yscDefenderSwizzlingInstanceMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass;

@end

/********************* NSObject+MethodSwizzling.m 文件 *********************/

#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (MethodSwizzling)

// 交换两个类方法的实现
+ (void)yscDefenderSwizzlingClassMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass {
swizzlingClassMethod(targetClass, originalSelector, swizzledSelector);
}

// 交换两个对象方法的实现
+ (void)yscDefenderSwizzlingInstanceMethod:(SEL)originalSelector withMethod:(SEL)swizzledSelector withClass:(Class)targetClass {
swizzlingInstanceMethod(targetClass, originalSelector, swizzledSelector);
}

// 交换两个类方法的实现 C 函数
void swizzlingClassMethod(Class class, SEL originalSelector, SEL swizzledSelector) {

Method originalMethod = class_getClassMethod(class, originalSelector);
Method swizzledMethod = class_getClassMethod(class, swizzledSelector);

BOOL didAddMethod = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));

if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}

// 交换两个对象方法的实现 C 函数
void swizzlingInstanceMethod(Class class, SEL originalSelector, SEL swizzledSelector) {
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

BOOL didAddMethod = class_addMethod(class,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));

if (didAddMethod) {
class_replaceMethod(class,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}

@end

4. Unrecognized Selector 防护

4.1 unrecognized selector sent to instance(找不到对象方法的实现)

如果被调用的对象方法没有实现,那么程序在运行中调用该方法时,就会因为找不到对应的方法实现,从而导致 APP 崩溃。比如下面这样的代码:


UIButton *testButton = [[UIButton alloc] init];
[testButton performSelector:@selector(someMethod:)];

testButton 是一个 UIButton 对象,而 UIButton 类中并没有实现 someMethod: 方法。所以向 testButoon 对象发送 someMethod: 方法,就会导致 testButoon 对象无法找到对应的方法实现,最终导致 APP 的崩溃。

那么有办法解决这类因为找不到方法的实现而导致程序崩溃的方法吗?

消息转发机制中三大步骤:消息动态解析消息接受者重定向消息重定向。通过这三大步骤,可以让我们在程序找不到调用方法崩溃之前,拦截方法调用。

大致流程如下:

  1. 消息动态解析:Objective-C 运行时会调用 +resolveInstanceMethod: 或者 +resolveClassMethod:,让你有机会提供一个函数实现。我们可以通过重写这两个方法,添加其他函数实现,并返回 YES, 那运行时系统就会重新启动一次消息发送的过程。若返回 NO 或者没有添加其他函数实现,则进入下一步。
  2. 消息接受者重定向:如果当前对象实现了 forwardingTargetForSelector:,Runtime 就会调用这个方法,允许我们将消息的接受者转发给其他对象。如果这一步方法返回 nil,则进入下一步。
  3. 消息重定向:Runtime 系统利用 methodSignatureForSelector: 方法获取函数的参数和返回值类型。
    • 如果 methodSignatureForSelector: 返回了一个 NSMethodSignature 对象(函数签名),Runtime 系统就会创建一个 NSInvocation 对象,并通过 forwardInvocation: 消息通知当前对象,给予此次消息发送最后一次寻找 IMP 的机会。
    • 如果 methodSignatureForSelector: 返回 nil。则 Runtime 系统会发出 doesNotRecognizeSelector:消息,程序也就崩溃了。

[图片上传失败...(image-5cdd82-1618276584627)]

这里我们选择第二步(消息接受者重定向)来进行拦截。因为 -forwardingTargetForSelector 方法可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写。

具体步骤如下:

  1. 给 NSObject 添加一个分类,在分类中实现一个自定义的 -ysc_forwardingTargetForSelector: 方法;
  2. 利用 Method Swizzling 将 -forwardingTargetForSelector: 和 -ysc_forwardingTargetForSelector: 进行方法交换。
  3. 在自定义的方法中,先判断当前对象是否已经实现了消息接受者重定向和消息重定向。如果都没有实现,就动态创建一个目标类,给目标类动态添加一个方法。
  4. 把消息转发给动态生成类的实例对象,由目标类动态创建的方法实现,这样 APP 就不会崩溃了。

实现代码如下:


#import "NSObject+SelectorDefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (SelectorDefender)

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

// 拦截 `-forwardingTargetForSelector:` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingInstanceMethod:@selector(forwardingTargetForSelector:)
withMethod:@selector(ysc_forwardingTargetForSelector:)
withClass:[NSObject class]];

});
}

// 自定义实现 `-ysc_forwardingTargetForSelector:` 方法
- (id)ysc_forwardingTargetForSelector:(SEL)aSelector {

SEL forwarding_sel = @selector(forwardingTargetForSelector:);

// 获取 NSObject 的消息转发方法
Method root_forwarding_method = class_getInstanceMethod([NSObject class], forwarding_sel);
// 获取 当前类 的消息转发方法
Method current_forwarding_method = class_getInstanceMethod([self class], forwarding_sel);

// 判断当前类本身是否实现第二步:消息接受者重定向
BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);

// 如果没有实现第二步:消息接受者重定向
if (!realize) {
// 判断有没有实现第三步:消息重定向
SEL methodSignature_sel = @selector(methodSignatureForSelector:);
Method root_methodSignature_method = class_getInstanceMethod([NSObject class], methodSignature_sel);

Method current_methodSignature_method = class_getInstanceMethod([self class], methodSignature_sel);
realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);

// 如果没有实现第三步:消息重定向
if (!realize) {
// 创建一个新类
NSString *errClassName = NSStringFromClass([self class]);
NSString *errSel = NSStringFromSelector(aSelector);
NSLog(@"出问题的类,出问题的对象方法 == %@ %@", errClassName, errSel);

NSString *className = @"CrachClass";
Class cls = NSClassFromString(className);

// 如果类不存在 动态创建一个类
if (!cls) {
Class superClsss = [NSObject class];
cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
// 注册类
objc_registerClassPair(cls);
}
// 如果类没有对应的方法,则动态添加一个
if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
class_addMethod(cls, aSelector, (IMP)Crash, "@@:@");
}
// 把消息转发到当前动态生成类的实例对象上
return [[cls alloc] init];
}
}
return [self ysc_forwardingTargetForSelector:aSelector];
}

// 动态添加的方法实现
static int Crash(id slf, SEL selector) {
return 0;
}

@end

4.2 unrecognized selector sent to class(找不到类方法实现)

同对象方法一样,如果被调用的类方法没有实现,那么同样也会导致 APP 崩溃。

例如,有这样一个类,声明了一个 + (id)aClassFunc; 的类方法, 但是并没有实现,就像下边的 YSCObject 这样。

/********************* YSCObject.h 文件 *********************/
#import <Foundation/Foundation.h>

@interface YSCObject : NSObject

+ (id)aClassFunc;

@end

/********************* YSCObject.m 文件 *********************/
#import "YSCObject.h"

@implementation YSCObject

@end

如果我们直接调用 [YSCObject aClassFunc]; 就会导致崩溃。

找不到类方法实现的解决方法和之前类似,我们可以利用 Method Swizzling 将 +forwardingTargetForSelector:和 +ysc_forwardingTargetForSelector: 进行方法交换。


#import "NSObject+SelectorDefender.h"
#import "NSObject+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation NSObject (SelectorDefender)

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

// 拦截 `+forwardingTargetForSelector:` 方法,替换自定义实现
[NSObject yscDefenderSwizzlingClassMethod:@selector(forwardingTargetForSelector:)
withMethod:@selector(ysc_forwardingTargetForSelector:)
withClass:[NSObject class]];
});
}

// 自定义实现 `+ysc_forwardingTargetForSelector:` 方法
+ (id)ysc_forwardingTargetForSelector:(SEL)aSelector {
SEL forwarding_sel = @selector(forwardingTargetForSelector:);

// 获取 NSObject 的消息转发方法
Method root_forwarding_method = class_getClassMethod([NSObject class], forwarding_sel);
// 获取 当前类 的消息转发方法
Method current_forwarding_method = class_getClassMethod([self class], forwarding_sel);

// 判断当前类本身是否实现第二步:消息接受者重定向
BOOL realize = method_getImplementation(current_forwarding_method) != method_getImplementation(root_forwarding_method);

// 如果没有实现第二步:消息接受者重定向
if (!realize) {
// 判断有没有实现第三步:消息重定向
SEL methodSignature_sel = @selector(methodSignatureForSelector:);
Method root_methodSignature_method = class_getClassMethod([NSObject class], methodSignature_sel);

Method current_methodSignature_method = class_getClassMethod([self class], methodSignature_sel);
realize = method_getImplementation(current_methodSignature_method) != method_getImplementation(root_methodSignature_method);

// 如果没有实现第三步:消息重定向
if (!realize) {
// 创建一个新类
NSString *errClassName = NSStringFromClass([self class]);
NSString *errSel = NSStringFromSelector(aSelector);
NSLog(@"出问题的类,出问题的类方法 == %@ %@", errClassName, errSel);

NSString *className = @"CrachClass";
Class cls = NSClassFromString(className);

// 如果类不存在 动态创建一个类
if (!cls) {
Class superClsss = [NSObject class];
cls = objc_allocateClassPair(superClsss, className.UTF8String, 0);
// 注册类
objc_registerClassPair(cls);
}
// 如果类没有对应的方法,则动态添加一个
if (!class_getInstanceMethod(NSClassFromString(className), aSelector)) {
class_addMethod(cls, aSelector, (IMP)Crash, "@@:@");
}
// 把消息转发到当前动态生成类的实例对象上
return [[cls alloc] init];
}
}
return [self ysc_forwardingTargetForSelector:aSelector];
}

// 动态添加的方法实现
static int Crash(id slf, SEL selector) {
return 0;
}

@end

将 4.1 和 4.2 结合起来就可以拦截所有未实现的类方法和对象方法了


作者:NJKNJK
链接:https://www.jianshu.com/p/bd8a2594b788





收起阅读 »

Crash拦截器 - 让unrecognized selector消失

在本文中,我们将了解到如下内容:基础的消息转发流程unrecognized selector 拦截建议快速转发(Fast Forwarding)拦截unrecognized selector常规转发(Normal Forwarding)拦截unrecogniz...
继续阅读 »

在本文中,我们将了解到如下内容:

  1. 基础的消息转发流程
  2. unrecognized selector 拦截建议
  3. 快速转发(Fast Forwarding)拦截unrecognized selector
  4. 常规转发(Normal Forwarding)拦截unrecognized selector

前言

我们在第一天学习Objective-C这一门语言的时候,就被告知这是一门动态语言。
C这样的编译语言,在编译阶段就确定了所有函数的调用链,如果函数没有被实现,编译就根本不过了。而基于动态语言的特性,在编译期间,我们无法确认程序在运行时要调用哪一个函数,某一个未被实现的函数是否会在运行时被实现。
这样就可能会出现运行时发现调用的函数根本不存在的尴尬,这也就是我们收到unrecognized selector sent to XXX这样的崩溃的原因了(动态语言也有让人心累的地方,手动叹气)。

这篇文章要讨论的就是如果遇到了这种尴尬情况的时候,我们该如何避免我们最最最讨厌的崩溃(是的,所有的崩溃都是最最最让人讨厌的)。

消息转发流程

我们知道在我们调用某一个方法之后,最终调用的是objc_msgSend()这样一个方法,发送消息(selector)给消息接收者(receiver)。这个方法会根据OC的消息发送机制在receiver中查找selector。如果没有查找到,就会出现上述的运行时调用了未实现的函数的尴尬局面了。

不过为了缓解这种尴尬,我们还有机会来挣扎。这挣扎机会就是消息转发流程

消息转发流程包含以下3个步骤:

  1. 动态方法解析:resolveInstanceMethod:resolveClassMethod:
  2. 消息转发
    • 快速转发:forwardingTargetForSelector:
    • 常规转发:methodSignatureForSelector:forwardInvocation:

消息转发流程是以动态方法解析消息快速转发消息常规转发这样的顺序来执行的。如果其中任意一个步骤能使消息被执行,那么就不会出现unrecognized selector sent to XXX的崩溃

动态方法解析

resolveInstanceMethod:这个方法的作用是动态地为selector提供一个实例方法的实现。而resolveClassMethod:则是提供一个类方法的实现。

所以我们可以在这两个方法中,为对象添加方法的实现,再返回YES告诉已经为selector添加了实现。这样就会重新在对象上查找方法,找到我们新添加的方法后就直接调用。从而避免掉unrecognized selector sent to XXX

需要注意的是: 这两个方法会响应respondsToSelector:instancesRespondToSelector:

消息快速转发

forwardingTargetForSelector:的作用是将消息转发给其它对象去处理。
我们可以在这个方法中,返回一个对象,让这个对象来响应消息。

需要注意的是: 如果在这个方法中返回selfnil,则表示没有可响应的目标。

消息常规转发

forwardInvocation:的作用也是将消息转发给其它对象。不过与 消息快速转发 不同的是该方法需要手动的创建一个NSInvocation对象,并手动地将新消息发送给新的接收者。

很显然,这种方式会比 消息快速转发 付出更大的消耗。

如何选择拦截方案的建议

对于以上的三个步骤,我们该选择哪一个步骤来进行拦截呢?

  • 动态方法解析 - 不建议
    1. 这个方法会为类添加本身不存在的方法,绝大多数情况下,这个方法时冗余的。
    2. respondsToSelector:instancesRespondToSelector:这两个方法都会调用到resolveInstanceMethod:,那么在我们需要使用这两个方法进行判断的时候,就会出现我们不想看到的情况。
  • 消息快速转发 - 推荐
    会拦截掉已经通过消息常规转发实现的消息转发,但是可以通过判断避开对NSObject子类的消息常规转发的拦截。
  • 消息常规转发 - 推荐
    这一步不会对原有的消息转发机制产生影响,缺点是更大的性能开销。

快速转发拦截方案

我们可以创建一个例如:crashPreventor的类,在forwardingTargetForSelector:中为crashPreventor添加selector,最后返回crashPreventor的实例。从而让crashPreventor的实例响应这个selector。具体代码如下:


@implementation
NSObject (SelectorPreventor)

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"
- (id)forwardingTargetForSelector:(SEL)aSelector{
Class rootClass = NSObject.class;
Class currentClass = self.class;
return [self.class actionForwardingTargetForSelector:aSelector rootClass:rootClass currentClass:currentClass];
}

+ (id)forwardingTargetForSelector:(SEL)aSelector {
Class rootClass = objc_getMetaClass(class_getName(NSObject.class));
Class currentClass = objc_getMetaClass(class_getName(self.class));
return [self actionForwardingTargetForSelector:aSelector rootClass:rootClass currentClass:currentClass];
}

+ (id)actionForwardingTargetForSelector:(SEL)aSelector rootClass:(Class)rootClass currentClass:(Class)currentClass {
// 过滤掉内部对象
NSString *className = NSStringFromClass(currentClass);
if ([className hasPrefix:@"_"]) {
return nil;
}

SEL methodSignatureSelector = @selector(methodSignatureForSelector:);
IMP rootMethodSignatureMethod = class_getMethodImplementation(rootClass, methodSignatureSelector);
IMP currentMethodSignatureMethod = class_getMethodImplementation(currentClass, methodSignatureSelector);
if (rootMethodSignatureMethod != currentMethodSignatureMethod) {
return nil;
}

NSString * selectorName = NSStringFromSelector(aSelector);

// 上报异常
// unrecognized selector sent to class XXX
// unrecognized selector sent to instance XXX
NSLog(@"unrecognized selector crash:%@:%@", className, selectorName);

// 创建crashPreventor类
NSString *targetClassName = @"crashPreventor";
Class cls = NSClassFromString(targetClassName);
if (!cls) {
// 如果要注册类,则必须要先判断class是否已经存在,否则会产生崩溃
// 如果不注册类,则可以重复创建class
cls = objc_allocateClassPair(NSObject.class, targetClassName.UTF8String, 0);
objc_registerClassPair(cls);
}

// 如果类没有对应的方法,则动态添加一个
if (!class_getInstanceMethod(cls, aSelector)) {
Method method = class_getInstanceMethod(currentClass, @selector(crashPreventor));
class_addMethod(cls, aSelector, method_getImplementation(method), method_getTypeEncoding(method));
}

return [cls new];
}

#pragma clang diagnostic pop

- (id)crashPreventor {
return nil;
}

@end

这里有几个点需要提一下:

  1. - (id)forwardingTargetForSelector:(SEL)aSelector;+ (id)forwardingTargetForSelector:(SEL)aSelector;都要在NSObject的分类中重写。前者对应实例方法,后者对应类方法。
  2. 过滤掉一些系统内部对象,否则在启动的时候就会有一些奇怪的异常被捕获到。
  3. 我们需要判断当前类是否实现了methodSignatureForSelector:方法,如果实现了该方法,就认为当前类已经实现了自己的消息转发机制,我们不对其进行拦截。
  4. 细心的我们肯定有注意到,不管是类方法还是实例方法,我们都是向crashPreventor中添加实例方法。这是因为,我们的响应对象时crashPreventor实例,而selector不区分实例方法还是类方法。我们这么处理最终对方法执行来说不会有什么差别。

常规转发拦截方案

实现比较简单,我们直接上代码:

@implementation NSObject (SelectorPreventor)

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
return [NSMethodSignature signatureWithObjCTypes:"@"];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation {
NSLog(@"forwardInvocation------");
}

+ (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
return [NSMethodSignature signatureWithObjCTypes:"@"];
}

+ (void)forwardInvocation:(NSInvocation *)anInvocation {
NSLog(@"forwardInvocation------");
}

#pragma clang diagnostic pop

@end

同样的,类方法和实例方法我们都需要重写。
methodSignatureForSelector:中我们返回一个返回值为voidNSMethodSignature,在forwardInvocation:中我们不做任何事情。这样将性能消耗减到最小。

以上:我们可以选择其中一种方式来实现我们对unrecognized selector的拦截,跟unrecognized selector彻底说拜拜啦(手动微笑)。



作者:一纸苍白
链接:https://www.jianshu.com/p/90b04882c595
收起阅读 »

自定义KVO(四)

四、KVOController上面Hook系统kvo相关方法的方式侵入太严重了,我们要做的其实只是需要对自己的调用负责而已,可以通过中间类来完成。这块有很多第三方框架,其中Facebook提供的KVOController是很优秀的一个框架。在这篇文章中将对这个...
继续阅读 »


四、KVOController

上面Hook系统kvo相关方法的方式侵入太严重了,我们要做的其实只是需要对自己的调用负责而已,可以通过中间类来完成。这块有很多第三方框架,其中Facebook提供的KVOController是很优秀的一个框架。在这篇文章中将对这个库进行简单分析。

4.1 KVOController 的使用

#import <KVOController/KVOController.h>


- (void)viewDidLoad {
[super viewDidLoad];

self.KVOController = [FBKVOController controllerWithObserver:self];
[self.KVOController observe:self.obj keyPath:@"name" options:NSKeyValueObservingOptionNew block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
NSLog(@"change:%@",change);
}];

[self.KVOController observe:self.obj keyPath:@"name" options:NSKeyValueObservingOptionNew block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
NSLog(@"change:%@",change);
}];

[self.KVOController observe:self.obj keyPath:@"nickName" options:NSKeyValueObservingOptionNew action:@selector(hp_NickNameChange:object:)];
}

- (void)hp_NickNameChange:(NSDictionary *)change object:(id)object {
NSLog(@"change:%@ object:%@",change,object);
}

输出:

change:{
FBKVONotificationKeyPathKey = name;
kind = 1;
new = HP111;
}
change:{
kind = 1;
new = cat111;
} object:<HPObject: 0x6000022c91d0>
  • vc持有FBKVOController实例KVOController。在NSObject+FBKVOController.h的关联属性。
  • 通过FBKVOController实例进行注册。注册方式提供了多种。
  • 对于重复添加会进行判断直接返回。
  • 会自动进行移除操作。

4.2 KVOController 实现分析

KVOController主要是使用了中介者模式,官方kvo使用麻烦的点在于使用需要三部曲。KVOController核心就是将三部曲进行了底层封装,上层只需要关心业务逻辑。

FBKVOController会进行注册、移除以及回调的处理(回调包括blockaction以及兼容系统的observe回调)。是对外暴露的交互类。使用FBKVOController分为两步:

  1. 使用 controllerWithObserver 初始化FBKVOController实例。
  2. 使用observe:进行注册。

4.2.1 FBKVOController 初始化

controllerWithObserver
controllerWithObserver最终会调用到initWithObserver中:

- (instancetype)initWithObserver:(nullable id)observer retainObserved:(BOOL)retainObserved
{
self = [super init];
if (nil != self) {
_observer = observer;
NSPointerFunctionsOptions keyOptions = retainObserved ? NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPointerPersonality : NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality;
_objectInfosMap = [[NSMapTable alloc] initWithKeyOptions:keyOptions valueOptions:NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPersonality capacity:0];
pthread_mutex_init(&_lock, NULL);
}
return self;
}

  • _observer是观察者,FBKVOController的属性。
@property (nullable, nonatomic, weak, readonly) id observer;

weak类型,因为FBKVOController本身被观察者持有了。

  • _objectInfosMap根据retainObserved进行NSMapTable内存管理初始化配置,FBKVOController的成员变量。其中保存的是一个被观察者对应多个_FBKVOInfo(也就是被观察对象对应多个keyPath):
  NSMapTable<id, NSMutableSet<_FBKVOInfo *> *> *_objectInfosMap;

这里_FBKVOInfo是放在NSMutableSet中的,说明是去重的。

4.2.2 FBKVOController 注册

由于各个observe方式的原理差不多,这里只分析block的形式。

- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block
{
NSAssert(0 != keyPath.length && NULL != block, @"missing required parameters observe:%@ keyPath:%@ block:%p", object, keyPath, block);
if (nil == object || 0 == keyPath.length || NULL == block) {
return;
}

// create info
_FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath options:options block:block];

// observe object with info
[self _observe:object info:info];
}
  • 首先一些条件容错判断。
  • 构造_FBKVOInfo。保存FBKVOControllerkeyPathoptions以及block
  • 调用_observe:(id)object info:(_FBKVOInfo *)info

4.2.2.1 _FBKVOInfo

@implementation _FBKVOInfo
{
@public
__weak FBKVOController *_controller;
NSString *_keyPath;
NSKeyValueObservingOptions _options;
SEL _action;
void *_context;
FBKVONotificationBlock _block;
_FBKVOInfoState _state;
}
  • _FBKVOInfo中保存了相关数据信息。

并且重写了isEqualhash方法:

- (NSUInteger)hash
{
return [_keyPath hash];
}

- (BOOL)isEqual:(id)object
{
if (nil == object) {
return NO;
}
if (self == object) {
return YES;
}
if (![object isKindOfClass:[self class]]) {
return NO;
}
return [_keyPath isEqualToString:((_FBKVOInfo *)object)->_keyPath];
}

说明只要_keyPath相同就认为是同一对象。

4.2.2.2 _observe: info:

- (void)_observe:(id)object info:(_FBKVOInfo *)info
{
// lock
pthread_mutex_lock(&_lock);

//从TableMap中获取 object(被观察者) 对应的 set
NSMutableSet *infos = [_objectInfosMap objectForKey:object];

// check for info existence
//判断对应的keypath info 是否存在
_FBKVOInfo *existingInfo = [infos member:info];
if (nil != existingInfo) {
//存在直接返回,这里就相当于对于同一个观察者排除了相同的keypath
// observation info already exists; do not observe it again

// unlock and return
pthread_mutex_unlock(&_lock);
return;
}

// lazilly create set of infos
//TableMap数据为空进行创建设置
if (nil == infos) {
infos = [NSMutableSet set];
//<被观察者 - keypaths info>
[_objectInfosMap setObject:infos forKey:object];
}

// add info and oberve
//keypaths info添加 keypath info
[infos addObject:info];

// unlock prior to callout
pthread_mutex_unlock(&_lock);
//注册
[[_FBKVOSharedController sharedController] observe:object info:info];
}
  • 首先判断kayPath是否已经被注册了,注册了直接返回,这里也就进行了去重处理。
  • 将构造的_FBKVOInfo信息添加进_objectInfosMap中。
  • 调用_FBKVOSharedController进行真正的注册。

member:说明
member会调用到_FBKVOInfo中的hash以及isEqual进行判断对象是否存在,也就是判断keyPath对应的对象是否存在。








官方API说明:







源码实现:

+ (NSUInteger)hash {
return _objc_rootHash(self);
}

- (NSUInteger)hash {
return _objc_rootHash(self);
}

+ (BOOL)isEqual:(id)obj {
return obj == (id)self;
}

- (BOOL)isEqual:(id)obj {
return obj == self;
}

uintptr_t
_objc_rootHash(id obj)
{
return (uintptr_t)obj;
}
  • hash默认实现将对象地址转换为uintptr_t类型返回。
  • isEqual:直接判断地址是否相同。
  • member:根据汇编可以看到大概逻辑是先计算参数的hash,然后集合中的元素调用isEqual参数是hash值。

4.2.2.3 _unobserve:info:

- (void)_unobserve:(id)object info:(_FBKVOInfo *)info
{
// lock
pthread_mutex_lock(&_lock);

// get observation infos
NSMutableSet *infos = [_objectInfosMap objectForKey:object];

// lookup registered info instance
_FBKVOInfo *registeredInfo = [infos member:info];

if (nil != registeredInfo) {
[infos removeObject:registeredInfo];

// remove no longer used infos
if (0 == infos.count) {
[_objectInfosMap removeObjectForKey:object];
}
}

// unlock
pthread_mutex_unlock(&_lock);

// unobserve
[[_FBKVOSharedController sharedController] unobserve:object info:registeredInfo];
}

- (void)_unobserve:(id)object
{
// lock
pthread_mutex_lock(&_lock);

NSMutableSet *infos = [_objectInfosMap objectForKey:object];

// remove infos
[_objectInfosMap removeObjectForKey:object];

// unlock
pthread_mutex_unlock(&_lock);

// unobserve
[[_FBKVOSharedController sharedController] unobserve:object infos:infos];
}

- (void)_unobserveAll
{
// lock
pthread_mutex_lock(&_lock);

NSMapTable *objectInfoMaps = [_objectInfosMap copy];

// clear table and map
[_objectInfosMap removeAllObjects];

// unlock
pthread_mutex_unlock(&_lock);

_FBKVOSharedController *shareController = [_FBKVOSharedController sharedController];

for (id object in objectInfoMaps) {
// unobserve each registered object and infos
NSSet *infos = [objectInfoMaps objectForKey:object];
[shareController unobserve:object infos:infos];
}
}

  • _unobserve提供了3个方法进行移除。分别对应keyPathobserverd(被观察对象)、observer(观察者)。
  • 最终都是通过_FBKVOSharedControllerunobserve进行移除。

4.2.3 _FBKVOSharedController

[[_FBKVOSharedController sharedController] observe:object info:info];

4.2.3.1 sharedController

_FBKVOSharedController是个单例,有成员变量_infos:

 NSHashTable<_FBKVOInfo *> *_infos;
不设计FBKVOController为单例是因为它被观察者持有,它是单例观察者就无法释放了。这里_infos存储的是所有类的_FBKVOInfo信息

- (instancetype)init
{
self = [super init];
if (nil != self) {
NSHashTable *infos = [NSHashTable alloc];
#ifdef __IPHONE_OS_VERSION_MIN_REQUIRED
_infos = [infos initWithOptions:NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
#elif defined(__MAC_OS_X_VERSION_MIN_REQUIRED)
if ([NSHashTable respondsToSelector:@selector(weakObjectsHashTable)]) {
_infos = [infos initWithOptions:NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
} else {
// silence deprecated warnings
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
_infos = [infos initWithOptions:NSPointerFunctionsZeroingWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
#pragma clang diagnostic pop
}

#endif
pthread_mutex_init(&_mutex, NULL);
}
return self;
}

  • infos的初始化是weak的,也就是它不影响_FBKVOInfo的引用计数。

4.2.3.2 observe: info:

- (void)observe:(id)object info:(nullable _FBKVOInfo *)info
{
if (nil == info) {
return;
}

// register info
pthread_mutex_lock(&_mutex);
[_infos addObject:info];
pthread_mutex_unlock(&_mutex);

// add observer
//被观察者调用官方kvo进行注册,context 传递的是 _FBKVOInfo 信息。
[object addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];

if (info->_state == _FBKVOInfoStateInitial) {
//状态变为Observing
info->_state = _FBKVOInfoStateObserving;
} else if (info->_state == _FBKVOInfoStateNotObserving) {
// this could happen when `NSKeyValueObservingOptionInitial` is one of the NSKeyValueObservingOptions,
// and the observer is unregistered within the callback block.
// at this time the object has been registered as an observer (in Foundation KVO),
// so we can safely unobserve it.
//当状态变为不在观察时移除
[object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];
}
}
  • 首先自己持有了传进来的info信息。
  • observe: info:中调用系统kvo方法观察注册。context传递的是_FBKVOInfo信息。
  • 对于系统而言观察者是_FBKVOSharedController

4.2.3.3 unobserve: info:

- (void)unobserve:(id)object info:(nullable _FBKVOInfo *)info
{
if (nil == info) {
return;
}

// unregister info
pthread_mutex_lock(&_mutex);
[_infos removeObject:info];
pthread_mutex_unlock(&_mutex);

// remove observer
if (info->_state == _FBKVOInfoStateObserving) {
[object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];
}
info->_state = _FBKVOInfoStateNotObserving;
}
  • 调用系统的removeObserver移除观察。

4.2.3.4 observeValueForKeyPath

既然是在4.2.3_FBKVOSharedController中进行的注册,那么系统的回调observeValueForKeyPath必然由它实现:

- (void)observeValueForKeyPath:(nullable NSString *)keyPath
ofObject:(nullable id)object
change:(nullable NSDictionary<NSString *, id> *)change
context:(nullable void *)context
{
NSAssert(context, @"missing context keyPath:%@ object:%@ change:%@", keyPath, object, change);

_FBKVOInfo *info;

{
// lookup context in registered infos, taking out a strong reference only if it exists
pthread_mutex_lock(&_mutex);
info = [_infos member:(__bridge id)context];
pthread_mutex_unlock(&_mutex);
}

if (nil != info) {

// take strong reference to controller
FBKVOController *controller = info->_controller;
if (nil != controller) {

// take strong reference to observer
//观察者
id observer = controller.observer;
if (nil != observer) {

// dispatch custom block or action, fall back to default action
if (info->_block) {
NSDictionary<NSString *, id> *changeWithKeyPath = change;
// add the keyPath to the change dictionary for clarity when mulitple keyPaths are being observed
if (keyPath) {
//将keypath加入字典中
NSMutableDictionary<NSString *, id> *mChange = [NSMutableDictionary dictionaryWithObject:keyPath forKey:FBKVONotificationKeyPathKey];
[mChange addEntriesFromDictionary:change];
changeWithKeyPath = [mChange copy];
}
info->_block(observer, object, changeWithKeyPath);
} else if (info->_action) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[observer performSelector:info->_action withObject:change withObject:object];
#pragma clang diagnostic pop
} else {
[observer observeValueForKeyPath:keyPath ofObject:object change:change context:info->_context];
}
}
}
}
}
  • info中获取观察者,info信息是context传递过来的。
  • _FBKVOInfo存在的情况下根据类型(blockaction、系统原始回调)进行了回调。block回调的过程中添加了keyPath

4.2.4 自动移除观察者

FBKVOControllerdealloc中调用了unobserveAll进行移除:

- (void)dealloc
{
[self unobserveAll];
pthread_mutex_destroy(&_lock);
}

由于FBKVOController的实例是被观察者持有的,所以当观察者dealloc的时候FBKVOController实例也就dealloc了。在这里调用就相当于在观察者dealloc中调用了移除。

FBKVOController流程




五、通过gnustep探索

kvokvc相关的代码苹果并没有开源,对于它们的探索可以通过gnustep查看原理,gnustep中有一些苹果早期底层的实现。



5.1 addObserver


  • setup()中是对一些表的初始化。
  • replacementForClass创建并注册kvo类。
  • 创建GSKVOInfo信息加入Map中。然后进行isa替换。
  • 重写setter方法。





  • 根据是否开启自动回调决定是否调用willChangeValueForKey以及didChangeValueForKey

didChangeValueForKey




最终调用了notifyForKey发送通知。

notifyForKey:ofInstance:prior:



收起阅读 »

自定义KVO(三)

三、系统kvo容错处理在上面自定义kvo中处理了自动移除观察者逻辑,以及将回调使用block实现。在实际使用系统kvo的时候有以下问题:1.多次添加同一观察者会进行多次回调。2.某个属性没有被观察,在dealloc中移除会造成crash。3.多次移除观察者也会...
继续阅读 »

三、系统kvo容错处理

在上面自定义kvo中处理了自动移除观察者逻辑,以及将回调使用block实现。在实际使用系统kvo的时候有以下问题:
1.多次添加同一观察者会进行多次回调。
2.某个属性没有被观察,在dealloc中移除会造成crash
3.多次移除观察者也会造成crash
4.不移除观察者有可能造成crash。(观察者释放后被观察者调用回调)

那么要避免就要在添加和移除以及dealloc过程中做容错处理。

NSObject(NSKeyValueObservingCustomization)中发现了observationInfo


/*
Take or return a pointer that identifies information about all of the observers that are registered with the receiver, the options that were used at registration-time, etc.
The default implementation of these methods store observation info in a global dictionary keyed by the receivers' pointers. For improved performance, you can override these methods to store the opaque data pointer in an instance variable.
Overrides of these methods must not attempt to send Objective-C messages to the passed-in observation info, including -retain and -release.
*/

@property (nullable) void *observationInfo NS_RETURNS_INNER_POINTER;
根据注释可以看到默认情况下observationInfo中保存了所有观察者信息。
那么observationInfo保存在哪里呢?直接代码验证下:


NSLog(@"observed before %@",self.obj.observationInfo);
NSLog(@"observe before %@",self.observationInfo);
[self.obj addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
NSLog(@"observe after %@",self.observationInfo);
NSLog(@"observed after %@",self.obj.observationInfo);
输出

observed before (null)
observe before (null)
observe after (null)
observed after <NSKeyValueObservationInfo 0x60000100c700> (
<NSKeyValueObservance 0x600001ee0c90: Observer: 0x7fd6eb112cb0, Key path: name, Options: <New: YES, Old: NO, Prior: NO> Context: 0x0, Property: 0x600001ee1050>
)

可以看到在注册后存入到了被观察者中,类型是NSKeyValueObservationInfo,它是一个私有类。NSKeyValueObservationInfo中保存的是NSKeyValueObservance
NSKeyValueObservationInfo保存了NSKeyValueObservance集合,NSKeyValueObservance中保存了观察者注册的时候的信息。
既然是在Foundation框架中,那么dump一下这个动态库的头文件(越狱手机使用classdump-dyld导出头文件)。

NSKeyValueObservationInfo头文件:

@class NSArray;
@interface NSKeyValueObservationInfo : NSObject {
NSArray* _observances;
unsigned long long _cachedHash;
BOOL _cachedIsShareable;
}
@property (nonatomic,readonly) BOOL containsOnlyInternalObservationHelpers;
-(void)dealloc;
-(unsigned long long)hash;
-(id)_initWithObservances:(id*)arg1 count:(unsigned long long)arg2 hashValue:(unsigned long long)arg3 ;
-(id)description;
-(BOOL)containsOnlyInternalObservationHelpers;
-(BOOL)isEqual:(id)arg1 ;
-(id)_copyByAddingObservance:(id)arg1 ;
@end

NSKeyValueObservance头文件:

@class NSObject, NSKeyValueProperty;
@interface NSKeyValueObservance : NSObject {
NSObject* _observer;
NSKeyValueProperty* _property;
void* _context;
NSObject* _originalObservable;
unsigned _options : 6;
unsigned _cachedIsShareable : 1;
unsigned _isInternalObservationHelper : 1;
}
-(id)_initWithObserver:(id)arg1 property:(id)arg2 options:(unsigned long long)arg3 context:(void*)arg4 originalObservable:(id)arg5 ;
-(unsigned long long)hash;
-(id)description;
-(BOOL)isEqual:(id)arg1 ;
-(void)observeValueForKeyPath:(id)arg1 ofObject:(id)arg2 change:(id)arg3 context:(void*)arg4 ;
@end
那么基本可以确定_observances中保存的是NSKeyValueObservance
代码验证:



它的定义如下:

@class NSKeyValueContainerClass, NSString;
@interface NSKeyValueProperty : NSObject <NSCopying> {
NSKeyValueContainerClass* _containerClass;
NSString* _keyPath;
}
-(Class)isaForAutonotifying;
-(id)_initWithContainerClass:(id)arg1 keyPath:(id)arg2 propertiesBeingInitialized:(CFSetRef)arg3 ;
-(id)dependentValueKeyOrKeysIsASet:(BOOL*)arg1 ;
-(void)object:(id)arg1 withObservance:(id)arg2 didChangeValueForKeyOrKeys:(id)arg3 recurse:(BOOL)arg4 forwardingValues:(SCD_Struct_NS48)arg5 ;
-(BOOL)object:(id)arg1 withObservance:(id)arg2 willChangeValueForKeyOrKeys:(id)arg3 recurse:(BOOL)arg4 forwardingValues:(SCD_Struct_NS48*)arg5 ;
-(void)object:(id)arg1 didAddObservance:(id)arg2 recurse:(BOOL)arg3 ;
-(id)restOfKeyPathIfContainedByValueForKeyPath:(id)arg1 ;
-(void)object:(id)arg1 didRemoveObservance:(id)arg2 recurse:(BOOL)arg3 ;
-(BOOL)matchesWithoutOperatorComponentsKeyPath:(id)arg1 ;
-(id)copyWithZone:(NSZone*)arg1 ;
-(id)keyPath;
-(void)dealloc;
-(id)keyPathIfAffectedByValueForKey:(id)arg1 exactMatch:(BOOL*)arg2 ;
-(id)keyPathIfAffectedByValueForMemberOfKeys:(id)arg1 ;
@end
  • _observer存储在NSKeyValueObservance 中。
  • _keyPath存储在NSKeyValueObservance_propertyNSKeyValueProperty)中。

3.1Hook注册和移除方法

要对系统方法进行容错处理那么最好的办法就是Hook了,直接对添加和移除的3个方法进行Hook处理:

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self hp_methodSwizzleWithClass:self oriSEL:@selector(addObserver:forKeyPath:options:context:) swizzledSEL:@selector(hp_addObserver:forKeyPath:options:context:) isClassMethod:NO];
[self hp_methodSwizzleWithClass:self oriSEL:@selector(removeObserver:forKeyPath:context:) swizzledSEL:@selector(hp_removeObserver:forKeyPath:context:)isClassMethod:NO];
[self hp_methodSwizzleWithClass:self oriSEL:@selector(removeObserver:forKeyPath:) swizzledSEL:@selector(hp_removeObserver:forKeyPath:)isClassMethod:NO];
});
}
  • 由于removeObserver:forKeyPath:底层调用的不是removeObserver:forKeyPath:context:所以两个方法都要Hook

那么核心逻辑就是怎么判断observer对应的keyPath是否存在。由于observationInfo存储的是私有类,那么直接通过kvc获取值:

- (BOOL)keyPathIsExist:(NSString *)sarchKeyPath observer:(id)observer {
BOOL findKey = NO;
id info = self.observationInfo;
if (info) {
NSArray *observances = [info valueForKeyPath:@"_observances"];
for (id observance in observances) {
id tempObserver = [observance valueForKey:@"_observer"];
if (tempObserver == observer) {
NSString *keyPath = [observance valueForKeyPath:@"_property._keyPath"];
if ([keyPath isEqualToString:sarchKeyPath]) {
findKey = YES;
break;
}
}
}
}
return findKey;
}

Hook的具体实现:

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
if ([self keyPathIsExist:keyPath observer:observer]) {//observer 观察者已经添加了对应key的观察,再次添加不做处理。
return;
}
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context {
if ([self keyPathIsExist:keyPath observer:observer]) {//key存在才移除
[self hp_removeObserver:observer forKeyPath:keyPath context:context];
}
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
if ([self keyPathIsExist:keyPath observer:observer]) {//key存在才移除
[self hp_removeObserver:observer forKeyPath:keyPath];
}
}

  • 这样就解决了重复添加和移除的问题。

3.2 自动移除观察者

3.1中解决了重复添加和移除的问题,还有一个问题是dealloc的时候自动移除。这块思路与自定义kvo相同,通过Hook观察者的的dealloc实现。

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
if ([self keyPathIsExist:keyPath observer:observer]) {//observer 观察者已经添加了对应key的观察,再次添加不做处理。
return;
}
NSString *className = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"NSKVONotifying_%@",className];
Class newClass = NSClassFromString(newClassName);
if (!newClass) {//类不存在的时候进行 hook 观察者 dealloc
//hook dealloc
[[observer class] hp_methodSwizzleWithClass:[observer class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
}

- (void)hp_dealloc {
[self hp_removeSelfAllObserverd];
[self hp_dealloc];
}
  • kvo子类已经存在的时候证明已经hook过了。

deallocself.observationInfo是获取不到信息的,因为observationInfo是存储在被观察者中的。所以还需要自己存储信息。
修改如下:

static NSString *const kHPSafeKVOObserverdAssiociateKey = @"HPSafeKVOObserverdAssiociateKey";

@interface HPSafeKVOObservedInfo : NSObject

@property (nonatomic, weak) id observerd;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, strong) id context;

@end

@implementation HPSafeKVOObservedInfo

- (instancetype)initWitObserverd:(NSObject *)observerd forKeyPath:(NSString *)keyPath context:(nullable void *)context {
if (self=[super init]) {
_observerd = observerd;
_keyPath = keyPath;
_context = (__bridge id)(context);
}
return self;
}

@end

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
if ([self keyPathIsExist:keyPath observer:observer]) {//observer 观察者已经添加了对应key的观察,再次添加不做处理。
return;
}
NSString *className = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"NSKVONotifying_%@",className];
Class newClass = NSClassFromString(newClassName);
if (!newClass) {//类不存在的时候进行 hook 观察者 dealloc
//hook dealloc
[[observer class] hp_methodSwizzleWithClass:[observer class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}

//保存被观察者信息
HPSafeKVOObservedInfo *kvoObservedInfo = [[HPSafeKVOObservedInfo alloc] initWitObserverd:self forKeyPath:keyPath context:context];
NSMutableArray *observerdArray = objc_getAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPSafeKVOObserverdAssiociateKey));
if (!observerdArray) {
observerdArray = [NSMutableArray arrayWithCapacity:1];
}
[observerdArray addObject:kvoObservedInfo];
objc_setAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPSafeKVOObserverdAssiociateKey), observerdArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

//调用原始方法
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
}

hp_dealloc中主动调用移除方法:

- (void)hp_dealloc {
[self hp_removeSelfAllObserverd];
[self hp_dealloc];
}

- (void)hp_removeSelfAllObserverd {
NSMutableArray *observerdArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPSafeKVOObserverdAssiociateKey));
for (HPSafeKVOObservedInfo *info in observerdArray) {
if (info.observerd) {
//调用系统方法,已经hook了,走hook逻辑。
if (info.context) {
[info.observerd removeObserver:self forKeyPath:info.keyPath context:(__bridge void * _Nullable)(info.context)];
} else {
[info.observerd removeObserver:self forKeyPath:info.keyPath];
}
}
}
}

这样在dealloc的时候就主动清空了已经释放掉的observer

3.3 问题处理

上面这样处理后在退出页面的时候发生了crash(非必现),堆栈如下:



UIScreen 观察了 CADisplay 的 cloned。但是在释放的过程中UIScreen却没有调用到Hookhp_dealloc中,对应的汇编实现:

int -[UIScreen dealloc](int arg0) {
[r0 _invalidate];
__UIScreenWriteDisplayConfiguration(r0, 0x0, 0x0);
r0 = [[&stack[0] super] dealloc];
return r0;
}

int -[UIScreen _invalidate](int arg0) {
var_10 = r20;
stack[-24] = r19;
r31 = r31 + 0xffffffffffffffe0;
saved_fp = r29;
stack[-8] = r30;
r19 = arg0;
*(int16_t *)(arg0 + 0xb0) = *(int16_t *)(arg0 + 0xb0) & 0xffffffffffffffcf;
r0 = [NSNotificationCenter defaultCenter];
r0 = [r0 retain];
[r0 removeObserver:r19];
[r0 release];
if ([r19 _isCarScreen] == 0x0) goto loc_7495b4;

loc_749570:
r0 = __UIInternalPreferenceUsesDefault_751e78(0x19080b0, @"ApplySceneUserInterfaceStyleToCarScreen", 0xec7178);
if (((*(int8_t *)0x19080b4 & 0x1) == 0x0) || (r0 != 0x0)) goto loc_74959c;

loc_7495b4:
[r19 _endObservingBacklightLevelNotifications];
[r19 _setSoftwareDimmingWindow:0x0];
r0 = *(r19 + 0x90);
r0 = [r0 _setScreen:0x0];
return r0;

loc_74959c:
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), r19, @"CarPlayUserInterfaceStyleDidChangeNotification", 0x0);
goto loc_7495b4;
}
那么意味着是否没有替换成功?
_UIScreenWriteDisplayConfiguration中确实先移除后添加:



是否进行注册是通过rax控制的。也就是__UIScreenIsCapturedValueOverride.isCapturedValue控制的。
经过测试只要在系统自动调用UIScreen initialize之前调用一个UIScreen相关方法就不走kvo设置逻辑了,比如:

[UIScreen mainScreen]
//[UIScreen class]

目前不清楚原因。所以处理这个问题有两个思路:

  1. + load进行方法交换的时候先调用[UIScreen class]
  1. 在注册的时候对系统类或者自己的类进行过滤。
  • 2.1只排除UIScreen
if ([observer isKindOfClass:[UIScreen class]]) {
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
return;
}
  • 2.2排除系统类
NSString *className = NSStringFromClass([observer class]);
if ([className hasPrefix:@"NS"] || [className hasPrefix:@"UI"]) { //排除某些系统类。
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
return;
}
  • 2.3 只处理自己的类
NSString *className = NSStringFromClass([observer class]);
if (![className hasPrefix:@"HP"]) { //排除某些系统类。
[self hp_addObserver:observer forKeyPath:keyPath options:options context:context];
return;
}


收起阅读 »

自定义KVO(二)

2.2.2 优化Hook逻辑上面在+ load中Hook dealloc方法是在NSObject分类中处理的,那么意味着所有的类的dealloc方法都被Hook了。显然这么做是不合理的。逻辑就是仅对需要的类进行Hook dealloc方法,所以将Hook延迟到...
继续阅读 »

2.2.2 优化Hook逻辑

上面在+ loadHook dealloc方法是在NSObject分类中处理的,那么意味着所有的类的dealloc方法都被Hook了。显然这么做是不合理的。
逻辑就是仅对需要的类进行Hook dealloc方法,所以将Hook延迟到addObserver中:

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(HPKVOBlock)block {
……
//hook dealloc
[[observer class] hp_methodSwizzleWithClass:[observer class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}

但是只应该对dealloc hook一次,否则又交换回来了。要么做标记,要么在创建kvo子类的时候进行hook。显然在创建子类的时候更合适。修改逻辑如下:
//申请类-注册类-添加方法
- (Class)_creatKVOClassWithKeyPath:(NSString *)keyPath observer:(NSObject *)observer {
//这里重写class后kvo子类也返回的是父类的名字
NSString *superClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@",kHPBlockKVOClassPrefix,superClassName];
Class newClass = NSClassFromString(newClassName);
//类是否存在
if (!newClass) {//不存在需要创建类
//1:申请类 父类、新类名称、额外空间
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
//2:注册类
objc_registerClassPair(newClass);
//3:添加class方法,class返回父类信息 这里是`-class`
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)_hp_class, classTypes);

//hook dealloc
[[observer class] hp_methodSwizzleWithClass:[observer class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}
//4:添加setter方法
SEL setterSEL = NSSelectorFromString(_setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)_hp_setter, setterTypes);

return newClass;
}

完整实现代码:

typedef void(^HPKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);

@interface NSObject (HP_KVO_Block)

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(HPKVOBlock)block;

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

@end

#import "NSObject+HP_KVO_Block.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import "HPKVOInfo.h"

static NSString *const kHPBlockKVOClassPrefix = @"HPKVONotifying_";
static NSString *const kHPBlockKVOAssiociateKey = @"HPKVOAssiociateKey";

static NSString *const kHPBlockKVOObserverdAssiociateKey = @"HPKVOObserverdAssiociateKey";

@interface HPKVOBlockInfo : NSObject

@property (nonatomic, weak) id observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, copy) HPKVOBlock handleBlock;

@end

@implementation HPKVOBlockInfo

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(HPKVOBlock)block {
if (self=[super init]) {
_observer = observer;
_keyPath = keyPath;
_handleBlock = block;
}
return self;
}

@end

@interface HPKVOObservedInfo : NSObject

@property (nonatomic, weak) id observerd;
@property (nonatomic, copy) NSString *keyPath;

@end

@implementation HPKVOObservedInfo

- (instancetype)initWitObserverd:(NSObject *)observerd forKeyPath:(NSString *)keyPath {
if (self=[super init]) {
_observerd = observerd;
_keyPath = keyPath;
}
return self;
}

@end

@implementation NSObject (HP_KVO_Block)


- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(HPKVOBlock)block {
//1.参数判断 以及 setter检查
if (!observer || !keyPath) return;
BOOL result = [self _handleSetterMethodFromKeyPath:keyPath];
if (!result) return;

//2.isa_swizzle 申请类-注册类-添加方法
Class newClass = [self _creatKVOClassWithKeyPath:keyPath observer:observer];

//3.isa 指向子类
object_setClass(self, newClass);
//4.setter逻辑处理
//保存观察者信息-数组
HPKVOBlockInfo *kvoInfo = [[HPKVOBlockInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey));
if (!observerArray) {
observerArray = [NSMutableArray arrayWithCapacity:1];
}
[observerArray addObject:kvoInfo];
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

//保存被观察者信息
HPKVOObservedInfo *kvoObservedInfo = [[HPKVOObservedInfo alloc] initWitObserverd:self forKeyPath:keyPath];
NSMutableArray *observerdArray = objc_getAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey));
if (!observerdArray) {
observerdArray = [NSMutableArray arrayWithCapacity:1];
}
[observerdArray addObject:kvoObservedInfo];
objc_setAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey), observerdArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey));
if (observerArray.count <= 0) {
return;
}

NSMutableArray *tempArray = [observerArray mutableCopy];
for (HPKVOInfo *info in tempArray) {
if ([info.keyPath isEqualToString:keyPath]) {
if (info.observer) {
if (info.observer == observer) {
[observerArray removeObject:info];
}
} else {
[observerArray removeObject:info];
}
}
}
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
//已经全部移除了
if (observerArray.count <= 0) {
//isa指回给父类
Class superClass = [self class];
object_setClass(self, superClass);
}
}

- (BOOL)_handleSetterMethodFromKeyPath:(NSString *)keyPath {
SEL setterSeletor = NSSelectorFromString(_setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(object_getClass(self), setterSeletor);
NSAssert(setterMethod, @"%@ setter is not exist",keyPath);
return setterMethod ? YES : NO;
}

// 从get方法获取set方法的名称 key -> setKey
static NSString *_setterForGetter(NSString *getter) {
if (getter.length <= 0) { return nil;}
NSString *firstString = [[getter substringToIndex:1] uppercaseString];
NSString *otherString = [getter substringFromIndex:1];
return [NSString stringWithFormat:@"set%@%@:",firstString,otherString];
}

//申请类-注册类-添加方法
- (Class)_creatKVOClassWithKeyPath:(NSString *)keyPath observer:(NSObject *)observer {
//这里重写class后kvo子类也返回的是父类的名字
NSString *superClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@",kHPBlockKVOClassPrefix,superClassName];
Class newClass = NSClassFromString(newClassName);
//类是否存在
if (!newClass) {//不存在需要创建类
//1:申请类 父类、新类名称、额外空间
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
//2:注册类
objc_registerClassPair(newClass);
//3:添加class方法,class返回父类信息 这里是`-class`
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)_hp_class, classTypes);

//hook dealloc
[[observer class] hp_methodSwizzleWithClass:[observer class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}
//4:添加setter方法
SEL setterSEL = NSSelectorFromString(_setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)_hp_setter, setterTypes);

return newClass;
}

//返回父类信息
Class _hp_class(id self,SEL _cmd) {
return class_getSuperclass(object_getClass(self));
}

static void _hp_setter(id self,SEL _cmd,id newValue) {
//自动开关判断,省略
//保存旧值
NSString *keyPath = _getterForSetter(NSStringFromSelector(_cmd));
id oldValue = [self valueForKey:keyPath];
//1.调用父类的setter(也可以通过performSelector调用)
void (*hp_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
struct objc_super super_struct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
hp_msgSendSuper(&super_struct,_cmd,newValue);

//2.通知观察者
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey));
for (HPKVOBlockInfo *info in observerArray) {//循环调用,可能添加多次。
if ([info.keyPath isEqualToString:keyPath] && info.handleBlock && info.observer) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
info.handleBlock(info.observer, keyPath, oldValue, newValue);
});
}
}
}

//获取getter
static NSString *_getterForSetter(NSString *setter){
if (setter.length <= 0 || ![setter hasPrefix:@"set"] || ![setter hasSuffix:@":"]) { return nil;}
NSRange range = NSMakeRange(3, setter.length - 4);
NSString *getter = [setter substringWithRange:range];
NSString *firstString = [[getter substringToIndex:1] lowercaseString];
return [getter stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstString];
}

+ (void)hp_methodSwizzleWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL isClassMethod:(BOOL)isClassMethod {
if (!cls) {
NSLog(@"class is nil");
return;
}
if (!swizzledSEL) {
NSLog(@"swizzledSEL is nil");
return;
}
//类/元类
Class swizzleClass = isClassMethod ? object_getClass(cls) : cls;
Method oriMethod = class_getInstanceMethod(swizzleClass, oriSEL);
Method swiMethod = class_getInstanceMethod(swizzleClass, swizzledSEL);
if (!oriMethod) {//原始方法没有实现
// 在oriMethod为nil时,替换后将swizzledSEL复制一个空实现
class_addMethod(swizzleClass, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
//添加一个空的实现
method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){
NSLog(@"imp default null implementation");
}));
}
//自己没有则会添加成功,自己有添加失败
BOOL success = class_addMethod(swizzleClass, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(oriMethod));
if (success) {//自己没有方法添加一个,添加成功则证明自己没有。
class_replaceMethod(swizzleClass, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
} else { //自己有直接进行交换
method_exchangeImplementations(oriMethod, swiMethod);
}
}

- (void)hp_dealloc {
NSMutableArray *observerdArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey));
for (HPKVOObservedInfo *info in observerdArray) {
if (info.observerd) {
[info.observerd hp_removeObserver:self forKeyPath:info.keyPath];
}
}
[self hp_dealloc];
}

@end


收起阅读 »

自定义KVO(一)

kvo1.1 hp_addObserver由于只有属性才有效,所以先进行容错处理。1.1.2 isa_swizzle动态生成子类static NSString *const kHPKVOClassPrefix = @"HPKVONotifying_"; //...
继续阅读 »

实现一个简单的kvo

@interface NSObject (HP_KVO)

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

@end

1.1 hp_addObserver

1.1.1参数检查

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
//1.参数判断 以及 setter检查
if (!observer || !keyPath) return;
BOOL result = [self handleSetterMethodFromKeyPath:keyPath];
if (!result) return;
}

- (BOOL)handleSetterMethodFromKeyPath:(NSString *)keyPath {
SEL setterSeletor = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(object_getClass(self), setterSeletor);
NSAssert(setterMethod, @"%@ setter is not exist",keyPath);
return setterMethod ? YES : NO;
}

// 从get方法获取set方法的名称 key -> setKey
static NSString *setterForGetter(NSString *getter) {
if (getter.length <= 0) { return nil;}
NSString *firstString = [[getter substringToIndex:1] uppercaseString];
NSString *otherString = [getter substringFromIndex:1];
return [NSString stringWithFormat:@"set%@%@:",firstString,otherString];
}

由于只有属性才有效,所以先进行容错处理。

1.1.2 isa_swizzle动态生成子类

static NSString *const kHPKVOClassPrefix = @"HPKVONotifying_";

//申请类-注册类-添加方法
- (Class)creatKVOClassWithKeyPath:(NSString *)keyPath {
//这里重写class后kvo子类也返回的是父类的名字
NSString *superClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@",kHPKVOClassPrefix,superClassName];
Class newClass = NSClassFromString(newClassName);
//类是否存在
if (!newClass) {//不存在需要创建类
//1:申请类 父类、新类名称、额外空间
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
//2:注册类
objc_registerClassPair(newClass);
//3:添加class方法,class返回父类信息 这里是`-class`
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)hp_class, classTypes);
}
//4:添加setter方法
SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)hp_setter, setterTypes);

return newClass;
}

//返回父类信息
Class hp_class(id self,SEL _cmd){
return class_getSuperclass(object_getClass(self));
}

static void hp_setter(id self,SEL _cmd,id newValue){

}
  • 根据类名字拼接kvo类名字,判断是否已经存在。(superClassName由于class重写了,即使二次进入也获取到的是父类的名字)。
  • newClass不存在则调用objc_allocateClassPair创建kvo子类。并且重写- class方法。
  • 添加对应的setter方法。

当然也可以写+class,写入元类中。在objc_allocateClassPair后元类就存在了:




1.1.3 isa 指向子类

object_setClass(self, newClass);
  • 直接调用object_setClass设置objisa为新创建的kvo子类。

object_setClass源码:


Class object_setClass(id obj, Class cls)
{
if (!obj) return nil;
if (!cls->isFuture() && !cls->isInitialized()) {
lookUpImpOrNilTryCache(nil, @selector(initialize), cls, LOOKUP_INITIALIZE);
}

return obj->changeIsa(cls);
}

源码中就是修改对象的isa指向。


1.1.4 setter逻辑

在进行了上面逻辑的处理后,这个时候调用如下代码:

[self.obj hp_addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
self.obj.name = @"HP";

会进入hp_setter函数。目前从HPObjectsetterName替换到了HPKVONotifying_ HPObjecthp_setter函数中。

hp_setter主要逻辑分两部分:调用父类方法以及发送通知

static void hp_setter(id self,SEL _cmd,id newValue) {
//自动开关判断,省略
//1.调用父类的setter(也可以通过performSelector调用)
void (*hp_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
struct objc_super super_struct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
hp_msgSendSuper(&super_struct,_cmd,newValue);

//2.通知观察者
[observer hp_observeValueForKeyPath:getterForSetter(_cmd) ofObject:self change:@{} context:NULL];
}
  • 调用父类方法可以通过objc_msgSendSuper实现。
  • 通知观察者keypath可以通过_cmd转换获取,objectselfchange也可以获取到,context可以先不传。那么核心就是observer的获取。

通知观察者
首先想到的是用属性存储observer,那么有个问题在类已经创建后就无法添加了。所以关联属性明显更合适。在hp_addObserver中添加关联对象:

static NSString *const kHPKVOAssiociateKey = @"HPKVOAssiociateKey";

objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey), observer, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

通知逻辑实现:

//2.通知观察者
id observer = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
[observer hp_observeValueForKeyPath:getterForSetter(NSStringFromSelector(_cmd)) ofObject:self change:@{@"kind":@1,@"new":newValue} context:NULL];

//获取getter
static NSString *getterForSetter(NSString *setter){
if (setter.length <= 0 || ![setter hasPrefix:@"set"] || ![setter hasSuffix:@":"]) { return nil;}
NSRange range = NSMakeRange(3, setter.length - 4);
NSString *getter = [setter substringWithRange:range];
NSString *firstString = [[getter substringToIndex:1] lowercaseString];
return [getter stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstString];
}

这个时候在hp_observeValueForKeyPath中就有回调了:

change:{
kind = 1;
new = HP;
}

1.1.5 观察者信息保存

上面的逻辑虽然简单实现了,但是存在一个严重问题,观察多个属性的时候以及新旧值都要观察以及传递了context的情况就无效了。
那么就需要保存观察者相关的信息,创建一个新类HPKVOInfo实现如下:

typedef NS_OPTIONS(NSUInteger, HPKeyValueObservingOptions) {
HPKeyValueObservingOptionNew = 0x01,
HPKeyValueObservingOptionOld = 0x02,
};

@interface HPKVOInfo : NSObject

@property (nonatomic, weak) id observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, assign) HPKeyValueObservingOptions options;
@property (nonatomic, strong) id context;

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(HPKeyValueObservingOptions)options context:(nullable void *)context;

@end

@implementation HPKVOInfo

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(HPKeyValueObservingOptions)options context:(nullable void *)context {
self = [super init];
if (self) {
self.observer = observer;
self.keyPath = keyPath;
self.options = options;
self.context = (__bridge id _Nonnull)(context);
}
return self;
}

@end

hp_addObserver中信息保存修改如下:

//保存观察者信息-数组
HPKVOInfo *kvoInfo = [[HPKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath options:options context:context];
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
if (!observerArray) {
observerArray = [NSMutableArray arrayWithCapacity:1];
}
[observerArray addObject:kvoInfo];
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

hp_setter逻辑修改如下:

static void hp_setter(id self,SEL _cmd,id newValue) {
//自动开关判断,省略
//保存旧值
NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
id oldValue = [self valueForKey:keyPath];
//1.调用父类的setter(也可以通过performSelector调用)
void (*hp_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
struct objc_super super_struct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
hp_msgSendSuper(&super_struct,_cmd,newValue);

//2.通知观察者
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
for (HPKVOInfo *info in observerArray) {//循环调用,可能添加多次。
if ([info.keyPath isEqualToString:keyPath]) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableDictionary<NSKeyValueChangeKey,id> *change = [NSMutableDictionary dictionaryWithCapacity:1];
//对新旧值进行处理
if (info.options & HPKeyValueObservingOptionNew) {
[change setObject:newValue forKey:NSKeyValueChangeNewKey];
}
if (info.options & HPKeyValueObservingOptionOld) {
if (oldValue) {
[change setObject:oldValue forKey:NSKeyValueChangeOldKey];
} else {
[change setObject:@"" forKey:NSKeyValueChangeOldKey];
}
}
[change setObject:@1 forKey:@"kind"];
//消息发送给观察者
[info.observer hp_observeValueForKeyPath:keyPath ofObject:self change:change context:(__bridge void * _Nullable)(info.context)];
});
}
}
}
  • 在调用父类之前先获取旧值。
  • 取出关联对象数组数据,循环判断调用hp_observeValueForKeyPath通知观察者。

这个时候观察多个属性以及多次观察就都没问题了。

1.2 hp_removeObserver

观察者对象是保存在关联对象中,所以在移除的时候也需要删除关联对象,并且当没有观察者时就要回复isa指向了。

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
[self hp_removeObserver:observer forKeyPath:keyPath context:NULL];
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context {
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
if (observerArray.count <= 0) {
return;
}

NSMutableArray *tempArray = [observerArray mutableCopy];
for (HPKVOInfo *info in tempArray) {
if ([info.keyPath isEqualToString:keyPath]) {
if (info.observer) {
if (info.observer == observer) {
if (context != NULL) {
if (info.context == context) {
[observerArray removeObject:info];
}
} else {
[observerArray removeObject:info];
}
}
} else {
if (context != NULL) {
if (info.context == context) {
[observerArray removeObject:info];
}
} else {
[observerArray removeObject:info];
}
}
}
}
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
//已经全部移除了
if (observerArray.count <= 0) {
//isa指回给父类
Class superClass = [self class];
object_setClass(self, superClass);
}
}
  • 通过keyPath以及observercontext确定要移除的关联对象数据。
  • 当关联对象中没有数据的时候isa进行指回。

完整代码如下:

@interface NSObject (HP_KVO)

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(HPKeyValueObservingOptions)options context:(nullable void *)context;
- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

- (void)hp_observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

@end

#import "NSObject+HP_KVO.h"
#import
#import

static NSString *const kHPKVOClassPrefix = @"HPKVONotifying_";
static NSString *const kHPKVOAssiociateKey = @"HPKVOAssiociateKey";

@implementation NSObject (HP_KVO)

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(HPKeyValueObservingOptions)options context:(nullable void *)context {
//1.参数判断 以及 setter检查
if (!observer || !keyPath) return;
BOOL result = [self handleSetterMethodFromKeyPath:keyPath];
if (!result) return;

//2.isa_swizzle 申请类-注册类-添加方法
Class newClass = [self creatKVOClassWithKeyPath:keyPath];

//3.isa 指向子类
object_setClass(self, newClass);
//4.setter逻辑处理
//保存观察者信息-数组
HPKVOInfo *kvoInfo = [[HPKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath options:options context:context];
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
if (!observerArray) {
observerArray = [NSMutableArray arrayWithCapacity:1];
}
[observerArray addObject:kvoInfo];
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
[self hp_removeObserver:observer forKeyPath:keyPath context:NULL];
}

- (void)hp_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context {
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
if (observerArray.count <= 0) {
return;
}

NSMutableArray *tempArray = [observerArray mutableCopy];
for (HPKVOInfo *info in tempArray) {
if ([info.keyPath isEqualToString:keyPath]) {
if (info.observer) {
if (info.observer == observer) {
if (context != NULL) {
if (info.context == context) {
[observerArray removeObject:info];
}
} else {
[observerArray removeObject:info];
}
}
} else {
if (context != NULL) {
if (info.context == context) {
[observerArray removeObject:info];
}
} else {
[observerArray removeObject:info];
}
}
}
}
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey), observerArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
//已经全部移除了
if (observerArray.count <= 0) {
//isa指回给父类
Class superClass = [self class];
object_setClass(self, superClass);
}
}

- (BOOL)handleSetterMethodFromKeyPath:(NSString *)keyPath {
SEL setterSeletor = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(object_getClass(self), setterSeletor);
NSAssert(setterMethod, @"%@ setter is not exist",keyPath);
return setterMethod ? YES : NO;
}

// 从get方法获取set方法的名称 key -> setKey
static NSString *setterForGetter(NSString *getter) {
if (getter.length <= 0) { return nil;}
NSString *firstString = [[getter substringToIndex:1] uppercaseString];
NSString *otherString = [getter substringFromIndex:1];
return [NSString stringWithFormat:@"set%@%@:",firstString,otherString];
}

//申请类-注册类-添加方法
- (Class)creatKVOClassWithKeyPath:(NSString *)keyPath {
//这里重写class后kvo子类也返回的是父类的名字
NSString *superClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@",kHPKVOClassPrefix,superClassName];
Class newClass = NSClassFromString(newClassName);
//类是否存在
if (!newClass) {//不存在需要创建类
//1:申请类 父类、新类名称、额外空间
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
//2:注册类
objc_registerClassPair(newClass);
//3:添加class方法,class返回父类信息 这里是`-class`
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)hp_class, classTypes);
}
//4:添加setter方法
SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)hp_setter, setterTypes);

return newClass;
}

//返回父类信息
Class hp_class(id self,SEL _cmd) {
return class_getSuperclass(object_getClass(self));
}

static void hp_setter(id self,SEL _cmd,id newValue) {
//自动开关判断,省略
//保存旧值
NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
id oldValue = [self valueForKey:keyPath];
//1.调用父类的setter(也可以通过performSelector调用)
void (*hp_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
struct objc_super super_struct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
hp_msgSendSuper(&super_struct,_cmd,newValue);

//2.通知观察者
NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPKVOAssiociateKey));
for (HPKVOInfo *info in observerArray) {//循环调用,可能添加多次。
if ([info.keyPath isEqualToString:keyPath]) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableDictionary<NSKeyValueChangeKey,id> *change = [NSMutableDictionary dictionaryWithCapacity:1];
//对新旧值进行处理
if (info.options & HPKeyValueObservingOptionNew) {
[change setObject:newValue forKey:NSKeyValueChangeNewKey];
}
if (info.options & HPKeyValueObservingOptionOld) {
if (oldValue) {
[change setObject:oldValue forKey:NSKeyValueChangeOldKey];
} else {
[change setObject:@"" forKey:NSKeyValueChangeOldKey];
}
}
[change setObject:@1 forKey:@"kind"];
//消息发送给观察者
[info.observer hp_observeValueForKeyPath:keyPath ofObject:self change:change context:(__bridge void * _Nullable)(info.context)];
});
}
}
}

//获取getter
static NSString *getterForSetter(NSString *setter){
if (setter.length <= 0 || ![setter hasPrefix:@"set"] || ![setter hasSuffix:@":"]) { return nil;}
NSRange range = NSMakeRange(3, setter.length - 4);
NSString *getter = [setter substringWithRange:range];
NSString *firstString = [[getter substringToIndex:1] lowercaseString];
return [getter stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstString];
}

@end

二、kvo函数式编程

上面的自定义自定义kvo与系统的kvo实现都有一个问题,都需要三步曲。代码是分离的可读性并不好。

2.1 注册与回调绑定

可以定义一个block用来处理回调,这样就不需要回调方法了,注册和回调就可以在一起处理了。
直接修改注册方法为block实现:

typedef void(^HPKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);

- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(HPKVOBlock)block {
……
//保存观察者信息-数组
HPKVOBlockInfo *kvoInfo = [[HPKVOBlockInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
……
}
  • block实现也保存在HPKVOBlockInfo中,这样在回调的时候直接执行block实现就可以了。

修改回调逻辑:

NSMutableArray *observerArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOAssiociateKey));
for (HPKVOBlockInfo *info in observerArray) {//循环调用,可能添加多次。
if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
info.handleBlock(info.observer, keyPath, oldValue, newValue);
});
}
}

  • 在回调的时候直接将新值与旧值一起返回。

注册调用逻辑:

[self.obj hp_addObserver:self forKeyPath:@"name" block:^(id  _Nonnull observer, NSString * _Nonnull keyPath, id  _Nonnull oldValue, id  _Nonnull newValue) {
NSLog(@"block: oldValue:%@,newValue:%@",oldValue,newValue);
}];

这样就替换了回调函数为block实现了,注册和回调逻辑在一起了。

2.2 kvo自动销毁

上面虽然实现了注册和回调绑定,但是在观察者dealloc的时候仍然需要remove
那么怎么能自动释放不需要主动调用呢?

removeObserver的过程中主要做了两件事,移除关联对象数组中的数据以及指回isa。关联对象不移除的后果是会继续调用回调,那么在调用的时候判断下observer存不存在来处理是否回调就可以了。核心就在指回isa了。

2.2.1 Hook dealloc

首先想到的就是Hook dealloc方法:

+ (void)hp_methodSwizzleWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL isClassMethod:(BOOL)isClassMethod {
if (!cls) {
NSLog(@"class is nil");
return;
}
if (!swizzledSEL) {
NSLog(@"swizzledSEL is nil");
return;
}
//类/元类
Class swizzleClass = isClassMethod ? object_getClass(cls) : cls;
Method oriMethod = class_getInstanceMethod(swizzleClass, oriSEL);
Method swiMethod = class_getInstanceMethod(swizzleClass, swizzledSEL);
if (!oriMethod) {//原始方法没有实现
// 在oriMethod为nil时,替换后将swizzledSEL复制一个空实现
class_addMethod(swizzleClass, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
//添加一个空的实现
method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){
NSLog(@"imp default null implementation");
}));
}
//自己没有则会添加成功,自己有添加失败
BOOL success = class_addMethod(swizzleClass, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(oriMethod));
if (success) {//自己没有方法添加一个,添加成功则证明自己没有。
class_replaceMethod(swizzleClass, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
} else { //自己有直接进行交换
method_exchangeImplementations(oriMethod, swiMethod);
}
}

+ (void)load {
[self hp_methodSwizzleWithClass:[self class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(hp_dealloc) isClassMethod:NO];
}

- (void)hp_dealloc {
// [self.obj hp_removeObserver:self forKeyPath:@""];
[self hp_dealloc];
}
hp_dealloc中调用hp_removeObserver移除观察者。这里有个问题是被观察者和keypath从哪里来?这里相当于是观察者的dealloc中调用。所以可以通过在注册的时候对观察者添加关联对象保存被观察者和keyPath

static NSString *const kHPBlockKVOObserverdAssiociateKey = @"HPKVOObserverdAssiociateKey";

@interface HPKVOObservedInfo : NSObject

@property (nonatomic, weak) id observerd;
@property (nonatomic, copy) NSString *keyPath;

@end

@implementation HPKVOObservedInfo

- (instancetype)initWitObserverd:(NSObject *)observerd forKeyPath:(NSString *)keyPath {
if (self=[super init]) {
_observerd = observerd;
_keyPath = keyPath;
}
return self;
}

@end


- (void)hp_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(HPKVOBlock)block {
……
//保存被观察者信息
HPKVOObservedInfo *kvoObservedInfo = [[HPKVOObservedInfo alloc] initWitObserverd:self forKeyPath:keyPath];
NSMutableArray *observerdArray = objc_getAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey));
if (!observerdArray) {
observerdArray = [NSMutableArray arrayWithCapacity:1];
}
[observerdArray addObject:kvoObservedInfo];
objc_setAssociatedObject(observer, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey), observerdArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

  • kvoObservedInfo中保存的是self也就是被观察者。
  • 关联对象关联在observer也就是观察者身上。

这个时候在dealloc中遍历对其进行移除:

- (void)hp_dealloc {
NSMutableArray *observerdArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kHPBlockKVOObserverdAssiociateKey));
for (HPKVOObservedInfo *info in observerdArray) {
if (info.observerd) {
[info.observerd hp_removeObserver:self forKeyPath:info.keyPath];
}
}
[self hp_dealloc];
}

当然这里的方法执行只针对被观察者没有释放的情况,释放了observerd就不存在了不需要调用remove逻辑了。

篇幅有限 下片继续



作者:HotPotCat
链接:https://www.jianshu.com/p/a57d0d98cc21








收起阅读 »

Android 11源码分析:从Activity的setContent方法看渲染流初识Window

在上一篇的分析中,我们已经知道DecorView以下的部分弄的很明白了,但是对于DecorView是如何显示在我们的屏幕上还是不太清楚。所以接着分析DecorView与PhoneWindow与Activity具体是如何建立联系的。 我们先弄清楚两个问题:Dec...
继续阅读 »

在上一篇的分析中,我们已经知道DecorView以下的部分弄的很明白了,但是对于DecorView是如何显示在我们的屏幕上还是不太清楚。所以接着分析DecorView与PhoneWindow与Activity具体是如何建立联系的。 我们先弄清楚两个问题:

  1. DecorView何时绘制到屏幕中
  2. DecorView如何绘制到屏幕中(addView,removeView,upDateViewLayout)
  3. Activity是如何得到Touch事件的

DecorView何时绘制到屏幕中

Activity的启动流程中提到执行Activity的performLaunchActivity方法执行的时,先是通过反射创建了Activity,然后会调用Activity的attach方法,在上一篇我们知道attach方法做了PhoneWindow的初始化操作,再然后才是执行生命周期onCreate,而setContent方法又是在onCreate执行后,看起来一切都很合理。

先创建一个Activity,然后再为这个Activity创建一个PhoneWindow,执行onCreate,最后再把我们写的XML设置进去。

一切都准备就绪,但是我们到目前还是看不到界面,因为我们知道,Activity真正可见是在执行onResume的时候。所以对于DecorView何时绘制到屏幕中问题,其实答案已经出来了,接下来需要去代码里进行验证。

看过我之前文章的人知道找Activity不同生命周期具体执行代码要去TransactionExecutor里看performLifecycleSequence方法。

Activity的onRemune具体执行是在Activity的handleResumeActivity方法中,我们去看看这里是怎么处理PhoneWindow的。

android.app.ActivityThread

@Override
public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward,
String reason) {
......
// TODO Push resumeArgs into the activity for consideration
performResumeActivity内会触发Activity的onResume生命周期
final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);
......
r.window = r.activity.getWindow();
//获取到当前PhoneWindow的DecorView
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
......
关键代码
wm.addView(decor, l);
......
}

关键代码只有简单的一句wm.addView(decor, l);

找出WindowManagerImpl

这个wm是ViewManager类型,然后获取他的方法是Activity的WindowManager。先来搞清楚一下WindowManager和ViewManager是什么关系。

假装分析一波,能用ViewManager去接收WindowManager,说明WindowManager要么是ViewManager的子类,要么是实现了ViewManager接口。而且会使用wm.addView。就说明这个addView方法ViewManager里被定义了,在WindowManager中没有,否则不会这么去写。

其实也不用想这么多,各自点进去看看就知道了。

android.view.ViewManager

public interface ViewManager
{
public void addView(View view, ViewGroup.LayoutParams params);
public void updateViewLayout(View view, ViewGroup.LayoutParams params);
public void removeView(View view);
}

android.view.WindowManager
@SystemService(Context.WINDOW_SERVICE)
public interface WindowManager extends ViewManager {
......
}

ViewManager是个接口,里面虽然只有三个方法,但是我们却非常熟悉,这三个方法的重要性就不用多说了。

让我没想到的是WindowManager居然也是个接口......那addView具体的实现在哪呢。只能去getWindowManager具体找找了。

android.app.Activity
......
private WindowManager mWindowManager;
......
public WindowManager getWindowManager() {
return mWindowManager;
}

而的赋值操作在attach里

   final void attach(......){
mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(mWindowControllerCallback);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
......
给PhoneWindow设置一个WindowManager
mWindow.setWindowManager((WindowManager)context.getSystemService(Context.WINDOW_SERVICE),
mToken, mComponent.flattenToString(),
(info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0);
if (mParent != null) {
mWindow.setContainer(mParent.getWindow());
}
取出WindowManager来赋值给mWindowManager
mWindowManager = mWindow.getWindowManager();
}

所以想知道mWindowManager到底是啥还得看mWindow.getWindowManager。而mWindow.getWindowManager返回的又是上面的一行mWindow.setWindowManager,所以去setWindowManager看看吧

android.view.Window

......
private WindowManager mWindowManager;
......
public void setWindowManager(WindowManager wm, IBinder appToken, String appName,
boolean hardwareAccelerated) {
mAppToken = appToken;
mAppName = appName;
mHardwareAccelerated = hardwareAccelerated;
if (wm == null) {
wm = (WindowManager)mContext.getSystemService(Context.WINDOW_SERVICE);
}
关键代码
mWindowManager = ((WindowManagerImpl)wm).createLocalWindowManager(this);
}
......
public WindowManager getWindowManager() {
return mWindowManager;
}

真相浮出水面

mWindowManager的真实对象是WindowManagerImpl

addView

这一小节我们来看看Window的添加过程

找了这么久我们就是为了弄明白wm.addView(decor, l);的wm到底是个啥。下面去看看addView到底把我们的DecorView怎么了。

android.view.WindowManagerImpl

public final class WindowManagerImpl implements WindowManager {
@UnsupportedAppUsage
private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance();
......
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow,
mContext.getUserId());
}
......
}

WindowManagerImpl居然又是个空壳,大部分操作都给了单例类WindowManagerGlobal去处理,

android.view.WindowManagerGlobal

private final ArrayList<View> mViews = new ArrayList<View>();

private final ArrayList<ViewRootImpl> mRoots = new ArrayList<ViewRootImpl>();

private final ArrayList<WindowManager.LayoutParams> mParams =
new ArrayList<WindowManager.LayoutParams>();
private final ArraySet<View> mDyingViews = new ArraySet<View>();

public void addView(View view, ViewGroup.LayoutParams params,Display display,
Window parentWindow, int userId) {
......
ViewRootImpl root;
View panelParentView = null;
synchronized (mLock) {
进行参数检查
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}
if (display == null) {
throw new IllegalArgumentException("display must not be null");
}
if (!(params instanceof WindowManager.LayoutParams)) {
throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
}

final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
如果是子Windowh还需要进行布局参数的调整
if (parentWindow != null) {
parentWindow.adjustLayoutParamsForSubWindow(wparams);
}
......
创建ViewRootImpl
root = new ViewRootImpl(view.getContext(), display);

view.setLayoutParams(wparams);

mViews.add(view);
mRoots.add(root);
mParams.add(wparams);

// do this last because it fires off messages to start doing things
try {
//把DecorView交给ViewRootImpl
root.setView(view, wparams, panelParentView, userId);
} catch (RuntimeException e) {
// BadTokenException or InvalidDisplayException, clean up.
if (index >= 0) {
removeViewLocked(index, true);
}
throw e;
}
}
}

首先我们看到WindowManagerGlobal维护了4个集合

  • mViews :存储了所有Window所对应的View

  • mRoots :存储的是所以Window所对应的ViewRootImpl

  • mParams:存储的是所有Window所对应的布局参数

  • mDyingViews:存储的是那些正在被删除的View

在addView中后面的关键代码创建了ViewRootImpl,并将我们的DecorView交给了它。

android.view.ViewRootImpl

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView,
int userId) {
synchronized (this) {
if (mView == null) {
mView = view;
}
......
触发一次屏幕刷新
requestLayout();
try {
将最后的操作给mWindowSession
res = mWindowSession.addToDisplayAsUser(mWindow, mSeq, mWindowAttributes,
getHostVisibility(), mDisplay.getDisplayId(), userId, mTmpFrame,
mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
mAttachInfo.mDisplayCutout, inputChannel,
mTempInsets, mTempControls);
setFrame(mTmpFrame);
} ......
// Set up the input pipeline.
输入事件处理
CharSequence counterSuffix = attrs.getTitle();
mSyntheticInputStage = new SyntheticInputStage();
InputStage viewPostImeStage = new ViewPostImeInputStage(mSyntheticInputStage);
InputStage nativePostImeStage = new NativePostImeInputStage(viewPostImeStage,
"aq:native-post-ime:" + counterSuffix);
InputStage earlyPostImeStage = new EarlyPostImeInputStage(nativePostImeStage);
InputStage imeStage = new ImeInputStage(earlyPostImeStage,
"aq:ime:" + counterSuffix);
InputStage viewPreImeStage = new ViewPreImeInputStage(imeStage);
InputStage nativePreImeStage = new NativePreImeInputStage(viewPreImeStage,
"aq:native-pre-ime:" + counterSuffix);
......
}
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();
}
}

setView方法先是做了个刷新布局的操作,内部执行的scheduleTraversals() 就是View绘制的入口,调用此方法后 ViewRootImpl 所关联的 View 也执行 measure - layout - draw 操作,确保在 View 被添加到 Window 上显示到屏幕之前,已经完成测量和绘制操作。至此由ViewRootImpl完成了添加View到Window的操作。 然后将调用mWindowSession的addToDisplayAsUser方法来完成Window的添加过程,内部真实执行的地方在WMS(WindowManagerService)

View的事件如何反馈到Activity

对于后面的输入事件处理的代码我也不是很明白,但是之前好像在哪里看过,所有对于利用管道和系统底层通信的机制有点印象。 这一块的简单的理解就是:这里是设置了一系列的输入通道。因为一个触屏事件的发生是肯定是由屏幕发起,再经过驱动层一系列的计算处理最后通过 Socket 跨进程通知 Android Framework 层。 其实看到这一块我一开始是不打算继续深入了,因为的也知道我的能力差不多就这了,但是抱着好奇的心点了进去想看看ViewPostImeInputStage是个啥。结果却有了意想不到的收获。

android.view.ViewRootImpl
final class ViewPostImeInputStage extends InputStage {
public ViewPostImeInputStage(InputStage next) {
super(next);
}

@Override
protected int onProcess(QueuedInputEvent q) {
if (q.mEvent instanceof KeyEvent) {
return processKeyEvent(q);
} else {
final int source = q.mEvent.getSource();
if ((source & InputDevice.SOURCE_CLASS_POINTER) != 0) {
只看这里,其实下面的两个都是类似
return processPointerEvent(q);
} else if ((source & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) {
return processTrackballEvent(q);
} else {
return processGenericMotionEvent(q);
}
}
}
}
private int processPointerEvent(QueuedInputEvent q) {
final MotionEvent event = (MotionEvent)q.mEvent;

mAttachInfo.mUnbufferedDispatchRequested = false;
mAttachInfo.mHandlingPointerEvent = true;
boolean handled = mView.dispatchPointerEvent(event);
......
return handled ? FINISH_HANDLED : FORWARD;
}

注意一下参数的传递就知道这个mView其实就是一开始我们DecorView。但是dispatchPointerEvent是在View定义的。

android.view.View
public final boolean dispatchPointerEvent(MotionEvent event) {
if (event.isTouchEvent()) {
return dispatchTouchEvent(event);
} else {
return dispatchGenericMotionEvent(event);
}
}

返回了dispatchTouchEvent和dispatchGenericMotionEvent的执行。所以去DecorView看看

com.android.internal.policy.DecorView

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
}

@Override
public boolean dispatchTrackballEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchTrackballEvent(ev) : super.dispatchTrackballEvent(ev);
}

@Override
public boolean dispatchGenericMotionEvent(MotionEvent ev) {
final Window.Callback cb = mWindow.getCallback();
return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
? cb.dispatchGenericMotionEvent(ev) : super.dispatchGenericMotionEvent(ev);
}

我们发现不管是哪个方法,最后都是从mWindow获取一个回调cb,然后把事回调出去。不知道读者对之前在Activity的attach里的时候的代码 mWindow.setCallback(this);还有没有印象,方法接收的参数类型是Window内部的一个接口Callback而我们的Activity实现了这个接口。所以mWindow.setCallback就是把Activity设置了进去,所以这个cb就是我们的Activity,所以DecorView的事件,就这样传递给了Activity

removeView

android.view.WindowManagerGlobal

@UnsupportedAppUsage
public void removeView(View view, boolean immediate) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}

synchronized (mLock) {
找到需要删除View的索引
int index = findViewLocked(view, true);
View curView = mRoots.get(index).getView();
真的执行删除操作
removeViewLocked(index, immediate);
if (curView == view) {
return;
}

throw new IllegalStateException("Calling with view " + view
+ " but the ViewAncestor is attached to " + curView);
}
}

Window的删除过程页是在WindowManagerGlobal中。先是找到需要删除View的索引,然后传递到removeViewLocked方法里。

   private void removeViewLocked(int index, boolean immediate) {
ViewRootImpl root = mRoots.get(index);
View view = root.getView();

if (root != null) {
root.getImeFocusController().onWindowDismissed();
}
boolean deferred = root.die(immediate);
if (view != null) {
view.assignParent(null);
if (deferred) {
mDyingViews.add(view);
}
}
}

removeViewLocked内部也是通过ViewRootImpl來完成刪除操作的。 在removeView方法里我们就看到了mRoots.get(index).getView(),里面又有View view = root.getView();ViewRootImpl的getView返回的View其实就是上面setView方法里,传进来的我们的顶层视图DecorView。

看看ViewRootImpl的die方法

Params:immediate – True, do now if not in traversal. False, put on queue and do later.
Returns:True, request has been queued. False, request has been completed.
boolean die(boolean immediate) {
// Make sure we do execute immediately if we are in the middle of a traversal or the damage
// done by dispatchDetachedFromWindow will cause havoc on return.
if (immediate && !mIsInTraversal) {
doDie();
return false;
}

if (!mIsDrawing) {
destroyHardwareRenderer();
} else {
Log.e(mTag, "Attempting to destroy the window while drawing!\n" +
" window=" + this + ", title=" + mWindowAttributes.getTitle());
}
mHandler.sendEmptyMessage(MSG_DIE);
return true;
}

参数immediates表示是否立即删除,返回false表示删除已完成,返回true表示加入待删除的队列里。看removeViewLocked的最后,将返回true加入了待删除的列表mDyingViews就明白了。 如果是需要立即删除则执行doDie,如果是是异步删除,则发送个消息,ViewRootImpl内部Handle接收消息的处理还是执行doDie。

现在看来doDie才是真正执行删除操作的地方。

void doDie() {
checkThread();
if (LOCAL_LOGV) Log.v(mTag, "DIE in " + this + " of " + mSurface);
synchronized (this) {
if (mRemoved) {
return;
}
mRemoved = true;
if (mAdded) {
关键代码
dispatchDetachedFromWindow();
}
......
全局单例WindowManagerGlobal也执行对应方法
WindowManagerGlobal.getInstance().doRemoveView(this);
}
void dispatchDetachedFromWindow() {
内部完成视图的移除
mView.dispatchDetachedFromWindow();
try {
内部通过WMA完成Window的删除
mWindowSession.remove(mWindow);
} catch (RemoteException e) {}
}

在dispatchDetachedFromWindow方法内部通过View的dispatchDetachedFromWindow方法完成View的删除,同时在通过mWindowSession来完成Window的删除

updateViewLayout

android.view.WindowManagerGlobal
public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}
if (!(params instanceof WindowManager.LayoutParams)) {
throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
}

final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params;
更新View参数
view.setLayoutParams(wparams);

synchronized (mLock) {
int index = findViewLocked(view, true);
ViewRootImpl root = mRoots.get(index);
mParams.remove(index);
mParams.add(index, wparams);
更新ViewRootImpl参数
root.setLayoutParams(wparams, false);
}
}

updateViewLayout内部做的事情比较简单,先是更新View的参数,然后更新ViewRootImpl的参数。

  • setLayoutParams内部会触发scheduleTraversals来对View重新布局,scheduleTraversals一旦触发,就会执行relayoutWindow方法,触发WMS来更新Window视图。我就不贴代码了,太多了,附上调用路径吧。
  • scheduleTraversals->mTraversalRunnable(TraversalRunnable)->doTraversal->performTraversals->relayoutWindow->内部执行mWindowSession.relayout通知WMS更新Window

总结

  1. DecorView在Activity的handleResumeActivity方法执行通过wm.addView完成操作。执行时机是在onResume后面一点点
  2. DecorView存在于PhoneWindow中,DecorView的添加删除更新曹组是由ViewRootImpl负责,而PhoneWindow的添加删除更新操作由WMS负责
  3. PhoneWindow的事件通过Callback接口回调给Activity
收起阅读 »

[Android翻译]解除对WindowManager的束缚

原文地址:medium.com/androiddeve…原文作者:medium.com/@pmaggi发布时间:2021年8月20日 - 6分钟阅读为可折叠设备和大屏幕设备优化应用程序Android的屏幕尺寸正在迅速变化,随着平板电脑和可折叠设备的不断普及,了...
继续阅读 »

原文地址:medium.com/androiddeve…

原文作者:medium.com/@pmaggi

发布时间:2021年8月20日 - 6分钟阅读

为可折叠设备和大屏幕设备优化应用程序

Android的屏幕尺寸正在迅速变化,随着平板电脑和可折叠设备的不断普及,了解你的应用程序的窗口尺寸和状态对于开发一个响应式的UI至关重要。Jetpack WindowManager现在处于测试阶段,它是一个库和API,提供类似于Android框架WindowManager的功能,包括对响应式UI的支持、检测屏幕变化的回调适配器以及窗口测试API。但Jetpack WindowManager还提供了对新型设备的支持,如可折叠设备和Chrome OS等窗口环境。

新的WindowManager APIs包括以下内容。

  • WindowLayoutInfo:包含了一个窗口的显示特征,例如窗口是否包含了折叠或铰链
  • FoldingFeature:使你能够监测可折叠设备的折叠状态,以确定设备的姿势
  • WindowMetrics:提供当前窗口的指标或整体显示的指标

Jetpack WindowManager与安卓系统没有捆绑,允许更快地迭代API,以快速支持快速发展的设备市场,并使应用程序开发人员能够采用库的更新,而不必等待最新的安卓版本。

现在该库已经进入测试阶段,我们鼓励所有的开发者采用Jetpack WindowManager,它具有设备无关的API,测试API,以及带来WindowMetrics,使你能够轻松应对窗口尺寸的变化。逐步过渡到测试版意味着你可以对你所采用的API有信心,使你可以完全专注于在这些设备上建立令人兴奋的体验。Jetpack WindowManager支持低至API 14的功能检测。

该库

Jetpack WindowManager是一个现代的、以Kotlin为首的库,它支持新的设备形态因素,并提供 "类似AppCompat "的功能,以构建具有响应式用户界面的应用程序。

折叠状态

这个库所提供的最明显的功能是对可折叠设备的支持。当设备的折叠状态发生变化时,应用程序可以接收事件,允许更新用户界面以支持新的用户互动。

1.gif

三星Galaxy Z Fold2上的Google Duo

请看这个Google Duo案例研究,它介绍了如何为可折叠设备添加支持。

有两种可能的折叠状态:平面半开放。对于FLAT,你可以认为表面是完全平坦地打开的,尽管在某些情况下它可能被铰链分割。对于HALF_OPENED,窗口至少有两个逻辑区域。下面,我们有图片说明每种状态的可能性。

image.png

折叠状态。平坦和半开放

当应用程序处于活动状态时,应用程序可以通过收集Kotlin流的事件来接收关于折叠状态变化的信息。 为了开始和停止事件收集,我们可以使用一个生命周期范围,正如 repeatOnLifeCycle API设计故事博文和下面的代码示例中所解释的。

lifecycleScope.launch(Dispatchers.Main) {
// The block passed to repeatOnLifecycle is executed when the lifecycle
// is at least STARTED and is cancelled when the lifecycle is STOPPED.
// It automatically restarts the block when the lifecycle is STARTED again.
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Safely collects from windowInfoRepository when the lifecycle is STARTED
// and stops collection when the lifecycle is STOPPED.
windowInfoRepository.windowLayoutInfo
.collect { newLayoutInfo ->
updateStateLog(newLayoutInfo)
updateCurrentState(newLayoutInfo)
}
}
}

然后,应用程序可以使用收到的WindowLayoutInfo对象中的可用信息,在应用程序对用户可见时更新其布局。

FoldingFeature包括铰链方向和折叠功能是否创建两个逻辑屏幕区域(isSeparating属性)等信息。我们可以使用这些值来检查设备是否处于桌面模式(半开,铰链水平)。

image.png

设备处于TableTop模式

private fun isTableTopMode(foldFeature: FoldingFeature) =
foldFeature.isSeparating &&
foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL

或处于书本模式(半开,铰链垂直)。

image.png

设备在书本模式下

private fun isBookMode(foldFeature: FoldingFeature) =
foldFeature.isSeparating &&
foldFeature.orientation == FoldingFeature.Orientation.VERTICAL

你可以在《可折叠设备上的桌面模式》一文中看到一个例子,说明如何为一个媒体播放器应用程序做到这一点。

注意:在主/UI线程上收集这些事件很重要,以避免UI和处理这些事件之间的同步问题。

对响应式UI的支持

由于安卓系统中的屏幕尺寸变化非常频繁,因此开始设计完全自适应和响应式的UI非常重要。WindowManager库中包含的另一个功能是能够检索当前和最大的窗口度量信息。这与API 30中包含的框架WindowMetrics API提供的信息类似,但它向后兼容到API 14。

Jetpack WindowManager提供了两种检索WindowMetrics信息的方式,作为流事件流或通过WindowMetricsCalculator类同步进行。

当在视图中写代码时,异步的API可能太难处理(比如onMeasure),就使用WindowMetricsCalculator。

val windowMetrics = 
WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity)

另一个用例是在测试中(见下面的测试)。

对于应用程序UI的更高层次的处理,使用WindowInfoRepository#currentWindowMetrics来获得库的通知,当有一个窗口大小的变化时,独立于这个变化是否触发了配置的变化。

下面是一个如何根据你的可用区域的大小来切换你的布局的例子。

// Create a new coroutine since repeatOnLifecycle is a suspend function
lifecycleScope.launch(Dispatchers.Main) {
// The block passed to repeatOnLifecycle is executed when the lifecycle
// is at least STARTED and is cancelled when the lifecycle is STOPPED.
// It automatically restarts the block when the lifecycle is STARTED again.
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Safely collect from currentWindowMetrics when the lifecycle is STARTED
// and stops collection when the lifecycle is STOPPED
windowInfoRepository.currentWindowMetrics
.collect { windowMetrics ->
val currentBounds = windowMetrics.bounds
Log.i(TAG, "New bounds: {$currentBounds}")
// We can update the layout if needed from here
}
}
}

回调适配器 要在Java编程语言中使用这个库,或者使用回调接口,请在你的应用程序中包含androidx.window:window-java依赖项。该工件提供了WindowInfoRepositoryCallbackAdapter,你可以用它来注册(和取消注册)一个回调,以接收设备姿态和窗口度量信息的更新。

public class SplitLayoutActivity extends AppCompatActivity {

private WindowInfoRepositoryCallbackAdapter windowInfoRepository;
private ActivitySplitLayoutBinding binding;
private final LayoutStateChangeCallback layoutStateChangeCallback =
new LayoutStateChangeCallback();

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

binding = ActivitySplitLayoutBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

windowInfoRepository =
new WindowInfoRepositoryCallbackAdapter(WindowInfoRepository.getOrCreate(this));
}

@Override
protected void onStart() {
super.onStart();
windowInfoRepository.addWindowLayoutInfoListener(Runnable::run, layoutStateChangeCallback);
}

@Override
protected void onStop() {
super.onStop();
windowInfoRepository.removeWindowLayoutInfoListener(layoutStateChangeCallback);
}

class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
@Override
public void accept(WindowLayoutInfo windowLayoutInfo) {
binding.splitLayout.updateWindowLayout(windowLayoutInfo);
}
}
}

测试

我们从开发者那里听说,更强大的测试API对于维持长期支持至关重要。让我们来谈谈如何在正常设备上测试可折叠的姿势。

到目前为止,我们已经看到Jetpack WindowManager库在设备姿态发生变化时通知你的应用程序,这样你就可以修改应用程序的布局。

该库在androidx.window:window-testing中提供了WindowLayoutInfoPublisherRule,它使你可以在测试FoldingFeature的支持下发布WindowInfoLayout。

import androidx.window.testing.layout.FoldingFeature
import androidx.window.testing.layout.WindowLayoutInfoPublisherRule

我们可以用它来创建一个假的FoldingFeature,在我们的测试中使用。

val feature = FoldingFeature(
activity = activity,
center = center,
size = 0,
orientation = VERTICAL,
state = HALF_OPENED
)
val expected =
WindowLayoutInfo.Builder().setDisplayFeatures(listOf(feature)).build()

publisherRule.overrideWindowLayoutInfo(expected)

然后使用WindowLayoutInfoPublisherRule来发布它。

val publisherRule = WindowLayoutInfoPublisherRule()

publisherRule.overrideWindowLayoutInfo(expected)

最后一步是使用可用的Espresso匹配器检查我们正在测试的活动的布局是否符合预期。

下面是一个测试发布FoldingFeature的例子,它在屏幕中心有一个HALF_OPENED的垂直铰链。

@Test
fun testDeviceOpen_Vertical(): Unit = testScope.runBlockingTest {
activityRule.scenario.onActivity { activity ->
val feature = FoldingFeature(
activity = activity,
orientation = VERTICAL,
state = HALF_OPENED
)
val expected =
WindowLayoutInfo.Builder().setDisplayFeatures(listOf(feature)).build()

val value = testScope.async {
activity.windowInfoRepository().windowLayoutInfo.first()
}
publisherRule.overrideWindowLayoutInfo(expected)
runBlockingTest {
Assert.assertEquals(
expected,
value.await()
)
}
}

// Checks that start_layout is on the left of end_layout with a vertical folding feature.
// This requires to run the test on a big enough screen to fit both views on screen
onView(withId(R.id.start_layout))
.check(isCompletelyLeftOf(withId(R.id.end_layout)))
}

请看它的运行情况。代码样本

GitHub上的一个最新样本显示了如何使用Jetpack WindowManager库来检索显示姿势信息,从WindowLayoutInfo流中收集信息或通过WindowInfoRepositoryCallbackAdapter注册一个回调。

该样本还包括一些测试,可以在任何设备或模拟器上运行。

在你的应用程序中采用WindowManager

可折叠和双屏设备不再是实验性的或未来主义的--大的显示区域和额外的姿势具有被证实的用户价值,而且现在有更多的设备可以供你的用户使用。可折叠设备和双屏设备代表了智能手机的自然进化。对于安卓开发者来说,他们提供了进入一个正在增长的高端市场的机会,这也得益于设备制造商的重新关注。

我们去年推出了Jetpack WindowManager alpha01。从那时起,该库有了稳定的发展,针对早期的反馈有了一些很大的改进。该库现在已经接受了Android的Kotlin优先理念,从回调驱动的模型过渡到coroutines和flow。随着WindowManager现在处于测试阶段,该API已经稳定,我们强烈建议采用。 而更新并不限于此。我们计划为该库添加更多的功能,并将其发展成一个用于系统UI的非捆绑式AppCompat,使开发者能够在所有的Android设备上轻松实现现代的、响应式的UI。


收起阅读 »

Android组件化开发笔记

Modularization什么是组件化组件化就是将一个app拆分成不同的组件,每一个组件都是一个独立的module。组件化的意义组件化能降低耦合性,而耦合性低就能提高维护性。于此同时由于组件间是独立的,所以组件与组件间耦合性低,所以我们在团队开发的时候可以以...
继续阅读 »

Modularization

什么是组件化

组件化就是将一个app拆分成不同的组件,每一个组件都是一个独立的module。

组件化的意义

组件化能降低耦合性,而耦合性低就能提高维护性。

于此同时由于组件间是独立的,所以组件与组件间耦合性低,所以我们在团队开发的时候可以以组件为分割单位,这样就能提高开发效率。

如何进行组件化

组件化是依靠gradle实现的。所以不会gradle的得去学学基础语法。当然也可以不学,只是说看别人写的代码看的半懂不懂的。

第一步创建Module

起点是一个全新的Project

image-20210818192909793

创建Module有好几种方法。

  • 右击new Module

    image-20210818193028977

    这个得注意位置哦,不然new出来全在app包下不是很好,通常我们的module是和app平级的。也就是说在大project下面。

    在我的这个demo中就是Modularization下面

  • 点击File new一个Module

    image-20210818193107528

这里我创建了3个module,一个lib

image-20210818193618429

注意命名,module是module_模块名,lib是lib__库名称.

简单区分以下module和lib,lib就是不显示页面的模块,module就是一个页面模块的集合。

将版本信息配置到一个gradle文件中

Tips:
fileName :app_versions.gradle

//applicationIds
def appIds = [:]
appIds.module_main = "com.example.module_main"
appIds.module_one = "com.example.module_one"
appIds.module_two = "com.example.module_two"
appIds.app = "com.example.modularization"
ext.appIds = appIds

将该gradle文件配置到project的build.gradle中

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
apply from: 'app_versions.gradle'
ext{
app_configs = "$rootDir/app_config.gradle"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20"

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

配置gradle的编译插件

先在gradle.properties,加入一个变量判断是否是发行状态

组件化中有两种状态,一种是debug状态,一个是发行状态,

  • debug状态也就是开发阶段,这个阶段每一个模块都是一个独立的app,可以独立运行
  • release发行状态,这个状态下只有app模块可以独立运行,其他的模块都是lib,依托于app模块。

image-20210818194002652

然后创建了一个app_config.gradle文件

image-20210818194518257

编写代码使得module_XX能在lib和app中切换状态。

这里有一点需要注意我们在gradle.propergies虽然写了一个isRelease的bool变量但是其实gradle这里获取的是一个string,得用toBoolean()进行转化。

image-20210818194838453

if (isRelease.toBoolean()){
if (project.name != 'app') apply plugin: 'com.android.library'
else apply plugin: 'com.andorid.application'
}
else{
if (project.name.matches('module_.+') || project.name == 'app') apply plugin: 'com.android.application'
else if (project.name.matches('lib_.+')) apply plugin: 'com.android.library'
}

然后加入必要的依赖

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

配置Manifest文件

android{
`````

sourceSets{
main{
if (isRelease.toBoolean()){
manifest.srcFile "src/main/AndroidManifest.xml"
}else {
if (project.name.matches('module_.+')){
manifest.srcFile "src/main/manifest/AndroidManifest.xml"
}else if (project.name.matches('lib_.+') || project.name == 'app'){
manifest.srcFile "src/main/AndroidManifest.xml"
}
}
}
}


`````
}

然后对module的manifest文件进行一点变动

在main文件夹下创建manifest文件夹,然后把debug状态的manifest的文件放进去。

image-20210818200716961

debug状态下的文件(这个状态下编译的文件是apk所以需要配置启动页和一些application的选项)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.module_main">


<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Modularization">

<activity
android:name=".MainMainActivity"
android:exported="true">

<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>

release状态下的manifest文件(这个状态下的编译文件是aar文件所以只需要注册一个activity,其余的都不需要)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.module_main">


<application>
<activity
android:name=".MainMainActivity"
android:exported="true" />

</application>

</manifest>

其余的module_XX,模块也是按照这样改。

配置applicationId

由于application才有applicationId,所以lib是没有applicationId的,而module在application和module之间疯狂切换,说以是有必要进行设置的。

if (isRelease.toBoolean()) {
applicationId "com.example.modularization"
}else {
if (project.name.matches('module_.+') || project.name == 'app') {
applicationId appIds[project.name]
}
}

之后在project的build.gradle中加点变量方便其gradle文件访问app_config.gradle文件(注意要加等号‘=’)

buildscript {
ext{
app_config = "$rootDir/app_config.gradle"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20"

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

module引用app_config.gradle

之前

plugins {
id 'com.android.application'
id 'kotlin-android'
}

android {
compileSdk 30

defaultConfig {
applicationId "com.example.module_main"
minSdk 21
targetSdk 30
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}

dependencies {

implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

之后

apply from: app_configs

dependencies {

implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

其余的lib和module都是这样

把android闭包下的一些属性进行抽离


def android_versions = [:]
android_versions.sdk_version = 30
android_versions.min_version = 21
android_versions.target_version = 30
android_versions.version_code = 1
android_versions.version_name = "1.0"

def kotlin_options = [:]
kotlin_options.jvm_target = '1.8'
android_versions.kotlin_options = kotlin_options

ext.android_versions = android_versions

app_config.gradle的内容

if (isRelease.toBoolean()){
if (project.name != 'app') apply plugin: 'com.android.library'
else apply plugin: 'com.andorid.application'
}
else{
if (project.name.matches('module_.+') || project.name == 'app') apply plugin: 'com.android.application'
else if (project.name.matches('lib_.+')) apply plugin: 'com.android.library'
}

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

android {
compileSdk android_versions.sdk_version

defaultConfig {
if (isRelease.toBoolean()) {
applicationId "com.example.modularization"
}else {
if (project.name.matches('module_.+') || project.name == 'app') {
applicationId appIds[project.name]
}
}

minSdk android_versions.min_version
targetSdk android_versions.target_version
versionCode android_versions.version_code
versionName android_versions.version_name

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

sourceSets{
main{
if (isRelease.toBoolean()){
manifest.srcFile "src/main/AndroidManifest.xml"
}else {
if (project.name.matches('module_.+')){
manifest.srcFile "src/main/manifest/AndroidManifest.xml"
}else if (project.name.matches('lib_.+') || project.name == 'app'){
manifest.srcFile "src/main/AndroidManifest.xml"
}
}
}
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = android_versions.kotlin_options.jvm_target
}
}
收起阅读 »

iOS 变化 一

变换很不幸,没人能告诉你母体是什么,你只能自己体会 -- 骇客帝国在第四章“可视效果”中,我们研究了一些增强图层和它的内容显示效果的一些技术,在这一章中,我们将要研究可以用来对图层旋转,摆放或者扭曲的CGAffineTransform,以及可以将扁平...
继续阅读 »

变换

很不幸,没人能告诉你母体是什么,你只能自己体会 -- 骇客帝国

在第四章“可视效果”中,我们研究了一些增强图层和它的内容显示效果的一些技术,在这一章中,我们将要研究可以用来对图层旋转,摆放或者扭曲的CGAffineTransform,以及可以将扁平物体转换成三维空间对象的CATransform3D(而不是仅仅对圆角矩形添加下沉阴影)。

5.1仿射变换

在第三章“图层几何学”中,我们使用了UIViewtransform属性旋转了钟的指针,但并没有解释背后运作的原理,实际上UIViewtransform属性是一个CGAffineTransform类型,用于在二维空间做旋转,缩放和平移。CGAffineTransform是一个可以和二维空间向量(例如CGPoint)做乘法的3X2的矩阵(见图5.1)。

图5.1

图5.1 用矩阵表示的CGAffineTransformCGPoint

CGPoint的每一列和CGAffineTransform矩阵的每一行对应元素相乘再求和,就形成了一个新的CGPoint类型的结果。要解释一下图中显示的灰色元素,为了能让矩阵做乘法,左边矩阵的列数一定要和右边矩阵的行数个数相同,所以要给矩阵填充一些标志值,使得既可以让矩阵做乘法,又不改变运算结果,并且没必要存储这些添加的值,因为它们的值不会发生变化,但是要用来做运算。

因此,通常会用3×3(而不是2×3)的矩阵来做二维变换,你可能会见到3行2列格式的矩阵,这是所谓的以列为主的格式,图5.1所示的是以行为主的格式,只要能保持一致,用哪种格式都无所谓。

当对图层应用变换矩阵,图层矩形内的每一个点都被相应地做变换,从而形成一个新的四边形的形状。CGAffineTransform中的“仿射”的意思是无论变换矩阵用什么值,图层中平行的两条线在变换之后任然保持平行,CGAffineTransform可以做出任意符合上述标注的变换,图5.2显示了一些仿射的和非仿射的变换:

图5.2

创建一个CGAffineTransform

对矩阵数学做一个全面的阐述就超出本书的讨论范围了,不过如果你对矩阵完全不熟悉的话,矩阵变换可能会使你感到畏惧。幸运的是,Core Graphics提供了一系列函数,对完全没有数学基础的开发者也能够简单地做一些变换。如下几个函数都创建了一个CGAffineTransform实例:

CGAffineTransformMakeRotation(CGFloat angle)
CGAffineTransformMakeScale(CGFloat sx, CGFloat sy)
CGAffineTransformMakeTranslation(CGFloat tx, CGFloat ty)

旋转和缩放变换都可以很好解释--分别旋转或者缩放一个向量的值。平移变换是指每个点都移动了向量指定的x或者y值--所以如果向量代表了一个点,那它就平移了这个点的距离。

我们用一个很简单的项目来做个demo,把一个原始视图旋转45度角度(图5.3)

图5.3

图5.3 使用仿射变换旋转45度角之后的视图

UIView可以通过设置transform属性做变换,但实际上它只是封装了内部图层的变换。

CALayer同样也有一个transform属性,但它的类型是CATransform3D,而不是CGAffineTransform,本章后续将会详细解释。CALayer对应于UIViewtransform属性叫做affineTransform,清单5.1的例子就是使用affineTransform对图层做了45度顺时针旋转。

清单5.1 使用affineTransform对图层旋转45度

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *layerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees
CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI_4);
self.layerView.layer.affineTransform = transform;
}

@end

注意我们使用的旋转常量是M_PI_4,而不是你想象的45,因为iOS的变换函数使用弧度而不是角度作为单位。弧度用数学常量pi的倍数表示,一个pi代表180度,所以四分之一的pi就是45度。

C的数学函数库(iOS会自动引入)提供了pi的一些简便的换算,M_PI_4于是就是pi的四分之一,如果对换算不太清楚的话,可以用如下的宏做换算:

#define RADIANS_TO_DEGREES(x) ((x)/M_PI*180.0)

混合变换

Core Graphics提供了一系列的函数可以在一个变换的基础上做更深层次的变换,如果做一个既要缩放又要旋转的变换,这就会非常有用了。例如下面几个函数:

CGAffineTransformRotate(CGAffineTransform t, CGFloat angle)
CGAffineTransformScale(CGAffineTransform t, CGFloat sx, CGFloat sy)
CGAffineTransformTranslate(CGAffineTransform t, CGFloat tx, CGFloat ty)

当操纵一个变换的时候,初始生成一个什么都不做的变换很重要--也就是创建一个CGAffineTransform类型的空值,矩阵论中称作单位矩阵,Core Graphics同样也提供了一个方便的常量:

CGAffineTransformIdentity

最后,如果需要混合两个已经存在的变换矩阵,就可以使用如下方法,在两个变换的基础上创建一个新的变换:

CGAffineTransformConcat(CGAffineTransform t1, CGAffineTransform t2);

我们来用这些函数组合一个更加复杂的变换,先缩小50%,再旋转30度,最后向右移动200个像素(清单5.2)。图5.4显示了图层变换最后的结果。

清单5.2 使用若干方法创建一个复合变换

- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CGAffineTransform transform = CGAffineTransformIdentity;
//scale by 50%
transform = CGAffineTransformScale(transform, 0.5, 0.5);
//rotate by 30 degrees
transform = CGAffineTransformRotate(transform, M_PI / 180.0 * 30.0);
//translate by 200 points
transform = CGAffineTransformTranslate(transform, 200, 0);
//apply transform to layer
self.layerView.layer.affineTransform = transform;
}

图5.4

图5.4 顺序应用多个仿射变换之后的结果

图5.4中有些需要注意的地方:图片向右边发生了平移,但并没有指定距离那么远(200像素),另外它还有点向下发生了平移。原因在于当你按顺序做了变换,上一个变换的结果将会影响之后的变换,所以200像素的向右平移同样也被旋转了30度,缩小了50%,所以它实际上是斜向移动了100像素。

这意味着变换的顺序会影响最终的结果,也就是说旋转之后的平移和平移之后的旋转结果可能不同。

#define DEGREES_TO_RADIANS(x) ((x)/180.0*M_PI)


收起阅读 »

iOS 视觉效果 四

4.6 组透明    UIView有一个叫做alpha的属性来确定视图的透明度。CALayer有一个等同的属性叫做opacity,这两个属性都是影响子层级的。也就是说,如果你给一个图层设置了opacity属性,那它的子图...
继续阅读 »

4.6 组透明

    UIView有一个叫做alpha的属性来确定视图的透明度。CALayer有一个等同的属性叫做opacity,这两个属性都是影响子层级的。也就是说,如果你给一个图层设置了opacity属性,那它的子图层都会受此影响。

    iOS常见的做法是把一个控件的alpha值设置为0.5(50%)以使其看上去呈现为不可用状态。对于独立的视图来说还不错,但是当一个控件有子视图的时候就有点奇怪了,图4.20展示了一个内嵌了UILabel的自定义UIButton;左边是一个不透明的按钮,右边是50%透明度的相同按钮。我们可以注意到,里面的标签的轮廓跟按钮的背景很不搭调。

图4.20

图4.20 右边的渐隐按钮中,里面的标签清晰可见

    这是由透明度的混合叠加造成的,当你显示一个50%透明度的图层时,图层的每个像素都会一半显示自己的颜色,另一半显示图层下面的颜色。这是正常的透明度的表现。但是如果图层包含一个同样显示50%透明的子图层时,你所看到的视图,50%来自子视图,25%来了图层本身的颜色,另外的25%则来自背景色。

    在我们的示例中,按钮和表情都是白色背景。虽然他们都是50%的可见度,但是合起来的可见度是75%,所以标签所在的区域看上去就没有周围的部分那么透明。所以看上去子视图就高亮了,使得这个显示效果都糟透了。

    理想状况下,当你设置了一个图层的透明度,你希望它包含的整个图层树像一个整体一样的透明效果。你可以通过设置Info.plist文件中的UIViewGroupOpacity为YES来达到这个效果,但是这个设置会影响到这个应用,整个app可能会受到不良影响。如果UIViewGroupOpacity并未设置,iOS 6和以前的版本会默认为NO(也许以后的版本会有一些改变)。

    另一个方法就是,你可以设置CALayer的一个叫做shouldRasterize属性(见清单4.7)来实现组透明的效果,如果它被设置为YES,在应用透明度之前,图层及其子图层都会被整合成一个整体的图片,这样就没有透明度混合的问题了(如图4.21)。

    为了启用shouldRasterize属性,我们设置了图层的rasterizationScale属性。默认情况下,所有图层拉伸都是1.0, 所以如果你使用了shouldRasterize属性,你就要确保你设置了rasterizationScale属性去匹配屏幕,以防止出现Retina屏幕像素化的问题。

    当shouldRasterizeUIViewGroupOpacity一起的时候,性能问题就出现了(我们在第12章『速度』和第15章『图层性能』将做出介绍),但是性能碰撞都本地化了(译者注:这句话需要再翻译)。

清单4.7 使用shouldRasterize属性解决组透明问题

@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *containerView;
@end

@implementation ViewController

- (UIButton *)customButton
{
//create button
CGRect frame = CGRectMake(0, 0, 150, 50);
UIButton *button = [[UIButton alloc] initWithFrame:frame];
button.backgroundColor = [UIColor whiteColor];
button.layer.cornerRadius = 10;

//add label
frame = CGRectMake(20, 10, 110, 30);
UILabel *label = [[UILabel alloc] initWithFrame:frame];
label.text = @"Hello World";
label.textAlignment = NSTextAlignmentCenter;
[button addSubview:label];
return button;
}

- (void)viewDidLoad
{
[super viewDidLoad];

//create opaque button
UIButton *button1 = [self customButton];
button1.center = CGPointMake(50, 150);
[self.containerView addSubview:button1];

//create translucent button
UIButton *button2 = [self customButton];

button2.center = CGPointMake(250, 150);
button2.alpha = 0.5;
[self.containerView addSubview:button2];

//enable rasterization for the translucent button
button2.layer.shouldRasterize = YES;
button2.layer.rasterizationScale = [UIScreen mainScreen].scale;
}
@end

图4.12

图4.21 修正后的图

总结

    这一章介绍了一些可以通过代码应用到图层上的视觉效果,比如圆角,阴影和蒙板。我们也了解了拉伸过滤器和组透明。

在第五章,『变换』中,我们将会研究图层变化和3D转换

收起阅读 »

iOS 视觉效果 三

4.4 图层蒙板    通过masksToBounds属性,我们可以沿边界裁剪图形;通过cornerRadius属性,我们还可以设定一个圆角。但是有时候你希望展现的内容不是在一个矩形或圆角矩形。比如,你想展示一个有星形框...
继续阅读 »

4.4 图层蒙板

    通过masksToBounds属性,我们可以沿边界裁剪图形;通过cornerRadius属性,我们还可以设定一个圆角。但是有时候你希望展现的内容不是在一个矩形或圆角矩形。比如,你想展示一个有星形框架的图片,又或者想让一些古卷文字慢慢渐变成背景色,而不是一个突兀的边界。

    使用一个32位有alpha通道的png图片通常是创建一个无矩形视图最方便的方法,你可以给它指定一个透明蒙板来实现。但是这个方法不能让你以编码的方式动态地生成蒙板,也不能让子图层或子视图裁剪成同样的形状。

    CALayer有一个属性叫做mask可以解决这个问题。这个属性本身就是个CALayer类型,有和其他图层一样的绘制和布局属性。它类似于一个子图层,相对于父图层(即拥有该属性的图层)布局,但是它却不是一个普通的子图层。不同于那些绘制在父图层中的子图层,mask图层定义了父图层的部分可见区域。

    mask图层的Color属性是无关紧要的,真正重要的是图层的轮廓。mask属性就像是一个饼干切割机,mask图层实心的部分会被保留下来,其他的则会被抛弃。(如图4.12)

    如果mask图层比父图层要小,只有在mask图层里面的内容才是它关心的,除此以外的一切都会被隐藏起来。

图4.12

图4.12 把图片和蒙板图层作用在一起的效果

    我们将代码演示一下这个过程,创建一个简单的项目,通过图层的mask属性来作用于图片之上。为了简便一些,我们用Interface Builder来创建一个包含UIImageView的图片图层。这样我们就只要代码实现蒙板图层了。清单4.5是最终的代码,图4.13是运行后的结果。

清单4.5 应用蒙板图层

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIImageView *imageView;
@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//create mask layer
CALayer *maskLayer = [CALayer layer];
maskLayer.frame = self.layerView.bounds;
UIImage *maskImage = [UIImage imageNamed:@"Cone.png"];
maskLayer.contents = (__bridge id)maskImage.CGImage;

//apply mask to image layer
self.imageView.layer.mask = maskLayer;
}
@end

图4.13

图4.13 使用了mask之后的UIImageView

    CALayer蒙板图层真正厉害的地方在于蒙板图不局限于静态图。任何有图层构成的都可以作为mask属性,这意味着你的蒙板可以通过代码甚至是动画实时生成。

4.5拉伸过滤

    最后我们再来谈谈minificationFiltermagnificationFilter属性。总得来讲,当我们视图显示一个图片的时候,都应该正确地显示这个图片(意即:以正确的比例和正确的1:1像素显示在屏幕上)。原因如下:

  • 能够显示最好的画质,像素既没有被压缩也没有被拉伸。
  • 能更好的使用内存,因为这就是所有你要存储的东西。
  • 最好的性能表现,CPU不需要为此额外的计算。

    不过有时候,显示一个非真实大小的图片确实是我们需要的效果。比如说一个头像或是图片的缩略图,再比如说一个可以被拖拽和伸缩的大图。这些情况下,为同一图片的不同大小存储不同的图片显得又不切实际。

    当图片需要显示不同的大小的时候,有一种叫做拉伸过滤的算法就起到作用了。它作用于原图的像素上并根据需要生成新的像素显示在屏幕上。

    事实上,重绘图片大小也没有一个统一的通用算法。这取决于需要拉伸的内容,放大或是缩小的需求等这些因素。CALayer为此提供了三种拉伸过滤方法,他们是:

  • kCAFilterLinear
  • kCAFilterNearest
  • kCAFilterTrilinear

    minification(缩小图片)和magnification(放大图片)默认的过滤器都是kCAFilterLinear,这个过滤器采用双线性滤波算法,它在大多数情况下都表现良好。双线性滤波算法通过对多个像素取样最终生成新的值,得到一个平滑的表现不错的拉伸。但是当放大倍数比较大的时候图片就模糊不清了。

    kCAFilterTrilinearkCAFilterLinear非常相似,大部分情况下二者都看不出来有什么差别。但是,较双线性滤波算法而言,三线性滤波算法存储了多个大小情况下的图片(也叫多重贴图),并三维取样,同时结合大图和小图的存储进而得到最后的结果。

    这个方法的好处在于算法能够从一系列已经接近于最终大小的图片中得到想要的结果,也就是说不要对很多像素同步取样。这不仅提高了性能,也避免了小概率因舍入错误引起的取样失灵的问题

图4.14

图4.14 对于大图来说,双线性滤波和三线性滤波表现得更出色

    kCAFilterNearest是一种比较武断的方法。从名字不难看出,这个算法(也叫最近过滤)就是取样最近的单像素点而不管其他的颜色。这样做非常快,也不会使图片模糊。但是,最明显的效果就是,会使得压缩图片更糟,图片放大之后也显得块状或是马赛克严重。

图4.15

图4.15 对于没有斜线的小图来说,最近过滤算法要好很多

    总的来说,对于比较小的图或者是差异特别明显,极少斜线的大图,最近过滤算法会保留这种差异明显的特质以呈现更好的结果。但是对于大多数的图尤其是有很多斜线或是曲线轮廓的图片来说,最近过滤算法会导致更差的结果。换句话说,线性过滤保留了形状,最近过滤则保留了像素的差异。

    让我们来实验一下。我们对第三章的时钟项目改动一下,用LCD风格的数字方式显示。我们用简单的像素字体(一种用像素构成字符的字体,而非矢量图形)创造数字显示方式,用图片存储起来,而且用第二章介绍过的拼合技术来显示(如图4.16)。

图4.16

图4.16 一个简单的运用拼合技术显示的LCD数字风格的像素字体

    我们在Interface Builder中放置了六个视图,小时、分钟、秒钟各两个,图4.17显示了这六个视图是如何在Interface Builder中放置的。如果每个都用一个淡出的outlets对象就会显得太多了,所以我们就用了一个IBOutletCollection对象把他们和控制器联系起来,这样我们就可以以数组的方式访问视图了。清单4.6是代码实现。

清单4.6 显示一个LCD风格的时钟

@interface ViewController ()

@property (nonatomic, strong) IBOutletCollection(UIView) NSArray *digitViews;
@property (nonatomic, weak) NSTimer *timer;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad]; //get spritesheet image
UIImage *digits = [UIImage imageNamed:@"Digits.png"];

//set up digit views
for (UIView *view in self.digitViews) {
//set contents
view.layer.contents = (__bridge id)digits.CGImage;
view.layer.contentsRect = CGRectMake(0, 0, 0.1, 1.0);
view.layer.contentsGravity = kCAGravityResizeAspect;
}

//start timer
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];

//set initial clock time
[self tick];
}

- (void)setDigit:(NSInteger)digit forView:(UIView *)view
{
//adjust contentsRect to select correct digit
view.layer.contentsRect = CGRectMake(digit * 0.1, 0, 0.1, 1.0);
}

- (void)tick
{
//convert time to hours, minutes and seconds
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier: NSGregorianCalendar];
NSUInteger units = NSHourCalendarUnit | NSMinuteCalendarUnit | NSSecondCalendarUnit;

NSDateComponents *components = [calendar components:units fromDate:[NSDate date]];

//set hours
[self setDigit:components.hour / 10 forView:self.digitViews[0]];
[self setDigit:components.hour % 10 forView:self.digitViews[1]];

//set minutes
[self setDigit:components.minute / 10 forView:self.digitViews[2]];
[self setDigit:components.minute % 10 forView:self.digitViews[3]];

//set seconds
[self setDigit:components.second / 10 forView:self.digitViews[4]];
[self setDigit:components.second % 10 forView:self.digitViews[5]];
}
@end

如图4.18,这样做的确起了效果,但是图片看起来模糊了。看起来默认的kCAFilterLinear选项让我们失望了。

图4.18

图4.18 一个模糊的时钟,由默认的kCAFilterLinear引起

    为了能像图4.19中那样,我们需要在for循环中加入如下代码:

view.layer.magnificationFilter = kCAFilterNearest;

图4.19

图4.19 设置了最近过滤之后的清晰显示


收起阅读 »

iOS 视觉效果 二

4.2 图层边框  &nbp; CALayer另外两个非常有用属性就是borderWidth和borderColor。二者共同定义了图层边的绘制样式。这条线(也被称作stroke)沿着图层的bounds绘制,同时也包含图层...
继续阅读 »

4.2 图层边框

  &nbp; CALayer另外两个非常有用属性就是borderWidthborderColor。二者共同定义了图层边的绘制样式。这条线(也被称作stroke)沿着图层的bounds绘制,同时也包含图层的角。

  &nbp; borderWidth是以点为单位的定义边框粗细的浮点数,默认为0.borderColor定义了边框的颜色,默认为黑色。

  &nbp; borderColor是CGColorRef类型,而不是UIColor,所以它不是Cocoa的内置对象。不过呢,你肯定也清楚图层引用了borderColor,虽然属性声明并不能证明这一点。CGColorRef在引用/释放时候的行为表现得与NSObject极其相似。但是Objective-C语法并不支持这一做法,所以CGColorRef属性即便是强引用也只能通过assign关键字来声明。

  &nbp; 边框是绘制在图层边界里面的,而且在所有子内容之前,也在子图层之前。如果我们在之前的示例中(清单4.2)加入图层的边框,你就能看到到底是怎么一回事了(如图4.3).

清单4.2 加上边框

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//set the corner radius on our layers
self.layerView1.layer.cornerRadius = 20.0f;
self.layerView2.layer.cornerRadius = 20.0f;

//add a border to our layers
self.layerView1.layer.borderWidth = 5.0f;
self.layerView2.layer.borderWidth = 5.0f;

//enable clipping on the second layer
self.layerView2.layer.masksToBounds = YES;
}

@end

图4.3

图4.3 给图层增加一个边框

  &nbp; 仔细观察会发现边框并不会把寄宿图或子图层的形状计算进来,如果图层的子图层超过了边界,或者是寄宿图在透明区域有一个透明蒙板,边框仍然会沿着图层的边界绘制出来(如图4.4).

图4.4

图4.4 边框是跟随图层的边界变化的,而不是图层里面的内容


4.3 阴影

    iOS的另一个常见特性呢,就是阴影。阴影往往可以达到图层深度暗示的效果。也能够用来强调正在显示的图层和优先级(比如说一个在其他视图之前的弹出框),不过有时候他们只是单纯的装饰目的。

    给shadowOpacity属性一个大于默认值(也就是0)的值,阴影就可以显示在任意图层之下。shadowOpacity是一个必须在0.0(不可见)和1.0(完全不透明)之间的浮点数。如果设置为1.0,将会显示一个有轻微模糊的黑色阴影稍微在图层之上。若要改动阴影的表现,你可以使用CALayer的另外三个属性:shadowColorshadowOffsetshadowRadius

    显而易见,shadowColor属性控制着阴影的颜色,和borderColorbackgroundColor一样,它的类型也是CGColorRef。阴影默认是黑色,大多数时候你需要的阴影也是黑色的(其他颜色的阴影看起来是不是有一点点奇怪。。)。

    shadowOffset属性控制着阴影的方向和距离。它是一个CGSize的值,宽度控制这阴影横向的位移,高度控制着纵向的位移。shadowOffset的默认值是 {0, -3},意即阴影相对于Y轴有3个点的向上位移。

    为什么要默认向上的阴影呢?尽管Core Animation是从图层套装演变而来(可以认为是为iOS创建的私有动画框架),但是呢,它却是在Mac OS上面世的,前面有提到,二者的Y轴是颠倒的。这就导致了默认的3个点位移的阴影是向上的。在Mac上,shadowOffset的默认值是阴影向下的,这样你就能理解为什么iOS上的阴影方向是向上的了(如图4.5).

图4.5

图4.5 在iOS(左)和Mac OS(右)上shadowOffset的表现。

    苹果更倾向于用户界面的阴影应该是垂直向下的,所以在iOS把阴影宽度设为0,然后高度设为一个正值不失为一个做法。

    shadowRadius属性控制着阴影的模糊度,当它的值是0的时候,阴影就和视图一样有一个非常确定的边界线。当值越来越大的时候,边界线看上去就会越来越模糊和自然。苹果自家的应用设计更偏向于自然的阴影,所以一个非零值再合适不过了。

    通常来讲,如果你想让视图或控件非常醒目独立于背景之外(比如弹出框遮罩层),你就应该给shadowRadius设置一个稍大的值。阴影越模糊,图层的深度看上去就会更明显(如图4.6).

图4.6

阴影裁剪

&nbps;   和图层边框不同,图层的阴影继承自内容的外形,而不是根据边界和角半径来确定。为了计算出阴影的形状,Core Animation会将寄宿图(包括子视图,如果有的话)考虑在内,然后通过这些来完美搭配图层形状从而创建一个阴影(见图4.7)。

图4.7

图4.7 阴影是根据寄宿图的轮廓来确定的

&nbps;   当阴影和裁剪扯上关系的时候就有一个头疼的限制:阴影通常就是在Layer的边界之外,如果你开启了masksToBounds属性,所有从图层中突出来的内容都会被才剪掉。如果我们在我们之前的边框示例项目中增加图层的阴影属性时,你就会发现问题所在(见图4.8).

图4.8

图4.8 maskToBounds属性裁剪掉了阴影和内容

&nbps;   从技术角度来说,这个结果是可以是可以理解的,但确实又不是我们想要的效果。如果你想沿着内容裁切,你需要用到两个图层:一个只画阴影的空的外图层,和一个用masksToBounds裁剪内容的内图层。

&nbps;   如果我们把之前项目的右边用单独的视图把裁剪的视图包起来,我们就可以解决这个问题(如图4.9).

图4.9

图4.9 右边,用额外的阴影转换视图包裹被裁剪的视图

&nbps;   我们只把阴影用在最外层的视图上,内层视图进行裁剪。清单4.3是代码实现,图4.10是运行结果。

清单4.3 用一个额外的视图来解决阴影裁切的问题

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *layerView1;
@property (nonatomic, weak) IBOutlet UIView *layerView2;
@property (nonatomic, weak) IBOutlet UIView *shadowView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//set the corner radius on our layers
self.layerView1.layer.cornerRadius = 20.0f;
self.layerView2.layer.cornerRadius = 20.0f;

//add a border to our layers
self.layerView1.layer.borderWidth = 5.0f;
self.layerView2.layer.borderWidth = 5.0f;

//add a shadow to layerView1
self.layerView1.layer.shadowOpacity = 0.5f;
self.layerView1.layer.shadowOffset = CGSizeMake(0.0f, 5.0f);
self.layerView1.layer.shadowRadius = 5.0f;

//add same shadow to shadowView (not layerView2)
self.shadowView.layer.shadowOpacity = 0.5f;
self.shadowView.layer.shadowOffset = CGSizeMake(0.0f, 5.0f);
self.shadowView.layer.shadowRadius = 5.0f;

//enable clipping on the second layer
self.layerView2.layer.masksToBounds = YES;
}

@end

图4.10

图4.10 右边视图,不受裁切阴影的阴影视图。

shadowPath属性

    我们已经知道图层阴影并不总是方的,而是从图层内容的形状继承而来。这看上去不错,但是实时计算阴影也是一个非常消耗资源的,尤其是图层有多个子图层,每个图层还有一个有透明效果的寄宿图的时候。

    如果你事先知道你的阴影形状会是什么样子的,你可以通过指定一个shadowPath来提高性能。shadowPath是一个CGPathRef类型(一个指向CGPath的指针)。CGPath是一个Core Graphics对象,用来指定任意的一个矢量图形。我们可以通过这个属性单独于图层形状之外指定阴影的形状。

图4.11 展示了同一寄宿图的不同阴影设定。如你所见,我们使用的图形很简单,但是它的阴影可以是你想要的任何形状。清单4.4是代码实现。

图4.11

图4.11 用shadowPath指定任意阴影形状

清单4.4 创建简单的阴影形状

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *layerView1;
@property (nonatomic, weak) IBOutlet UIView *layerView2;
@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];

//enable layer shadows
self.layerView1.layer.shadowOpacity = 0.5f;
self.layerView2.layer.shadowOpacity = 0.5f;

//create a square shadow
CGMutablePathRef squarePath = CGPathCreateMutable();
CGPathAddRect(squarePath, NULL, self.layerView1.bounds);
self.layerView1.layer.shadowPath = squarePath; CGPathRelease(squarePath);

//create a circular shadow
CGMutablePathRef circlePath = CGPathCreateMutable();
CGPathAddEllipseInRect(circlePath, NULL, self.layerView2.bounds);
self.layerView2.layer.shadowPath = circlePath; CGPathRelease(circlePath);
}
@end

    如果是一个矩形或者是圆,用CGPath会相当简单明了。但是如果是更加复杂一点的图形,UIBezierPath类会更合适,它是一个由UIKit提供的在CGPath基础上的Objective-C包装类。

图4.6 大一些的阴影位移和角半径会增加图层的深度即视感

收起阅读 »

Android数据库高手秘籍,如何在Kotlin中更好地使用LitePal

前言 自从 LitePal 在 2.0.0 版本中全面支持了 Kotlin 之后,我也一直在思考如何让 LitePal 更好地融入和适配 Kotlin 语言,而不仅仅停留在简单的支持层面。 Kotlin 确实是一门非常出色的语言,里面有许多优秀的特性是在 ...
继续阅读 »

前言


自从 LitePal 在 2.0.0 版本中全面支持了 Kotlin 之后,我也一直在思考如何让 LitePal 更好地融入和适配 Kotlin 语言,而不仅仅停留在简单的支持层面。


Kotlin 确实是一门非常出色的语言,里面有许多优秀的特性是在 Java 中无法实现的。因此,在 LitePal 全面支持了 Kotlin 之后,我觉得如果我还视这些优秀特性而不见的话,就有些太暴殄天物了。所以在最新的 LitePal 3.0.0 版本里面,我准备让 LitePal 更加充分地利用 Kotlin 的一些语言特性,从而让我们的开发更加轻松。


本篇文章除了介绍 LitePal 3.0.0 版本的升级内容之外,还会讲解一些 Kotlin 方面的高级知识。


首先还是来看如何升级。


升级的方式


为什么这次的版本号跨度如此之大,直接从 2.0 升到了 3.0 呢?因为这次 LitePal 在结构上面有了一个质的变化。


为了更好地兼容 Kotlin 语言,LitePal 现在不再只是一个库了,而是变成了两个库,根据你使用的语言不同,需要引入的库也不同。如果你使用的是 Java,那么就在 build.gradle 中引入如下配置:


dependencies {
implementation 'org.litepal.android:java:3.0.0'
}


而如果你使用的是 Kotlin,那么就在 build.gradle 中引入如下配置:


dependencies {
implementation 'org.litepal.android:kotlin:3.0.0'
}


好了,接下来我们就一起看一看 LitePal 3.0.0 版本到底变更了哪些东西。


不得不说,其实 LitePal 的泛型设计一直都不是很友好,尤其在异步查询的时候格外难受,比如我们看下如下代码:


在异步查询的onFinish()回调中,我们直接得到的并不是查询的对象,而是一个泛型 T 对象,还需要再经过一次强制转型才能得到真正想要查询的对象。


如果你觉得这还不算难受的话,那么再来看看下面这个例子:



可以看到,这次查询返回的是一个List<T>,我们必须要对整个 List 进行强制转型。不仅要多写一行代码,关键是开发工具还会给出一个很丑的警告。


这样的设计无论如何都算不上友好。


这里非常感谢 xiazunyang 这位朋友在 GitHub 上提出的这个 Issue(github.com/LitePalFram… 3.0.0 版本在泛型方面的优化很大程度上是基于他的建议。


那么我们现在来看看,到了 LitePal 3.0.0 版本,同样的功能可以怎么写:


LitePal.findAsync(Song.class, 1).listen(new FindCallback<Song>() {
@Override
public void onFinish(Song song) {

}
});


可以看到,这里在FindCallback接口上声明了泛型类型为Song,那么在onFinish()方法回调中的参数就可以直接指定为Song类型了,从而避免了一次强制类型转换。


那么同样地,在查询多条数据的时候就可以这样写:


LitePal.where("duration > ?", "100").findAsync(Song.class).listen(new FindMultiCallback<Song>() {
@Override
public void onFinish(List<Song> list) {

}
});


这次就清爽多了吧,在onFinish()回调方法中,我们直接拿到的就是一个List<Song>集合,而不会再出现那个丑丑的警告了。


而如果这段代码使用 Kotlin 来编写的话,将会更加的精简:


LitePal.where("duration > ?", "100").findAsync(Song::class.java).listen { list ->

}


得益于 Kotlin 出色的 lambda 机制,我们的代码可以得到进一步精简。在上述代码中,行尾的list参数就是查询出来的List<Song>集合了。


那么关于泛型优化的讲解就到这里,下面我们来看另一个主题,监听数据库的创建和升级。


没错,LitePal 3.0.0 版本新增了监听数据库的创建和升级功能。


加入这个功能是因为 JakeHao 这位朋友在 GitHub 上提了一个 Issue(github.com/LitePalFram…


)


要实现这个功能肯定要添加新的接口了,而我对于添加新接口保持着一种比较谨慎的态度,因为要考虑到接口的易用性和对整体框架的影响。


LitePal 的每一个接口我都要尽量将它设计得简单好用,因此大家应该也可以猜到了,监听数据库创建和升级这个功能会非常容易,只需要简单几行代码就可以了实现了:


LitePal.registerDatabaseListener(new DatabaseListener() {
@Override
public void onCreate() {
}

@Override
public void onUpgrade(int oldVersion, int newVersion) {
}
});


需要注意的是,registerDatabaseListener()方法一定要确保在任何其他数据库操作之前调用,然后当数据库创建的时候,onCreate()方法就会得到回调,当数据库升级的时候onUpgrade()方法就会得到回调,并且告诉通过参数告诉你之前的老版本号,以及升级之后的新版本号。


Kotlin 版的代码也是类似的,但是由于这个接口有两个回调方法,因此用不了 Kotlin 的单抽象方法 (SAM) 这种语法糖,只能使用实现接口的匿名对象这种写法:


LitePal.registerDatabaseListener(object : DatabaseListener {
override fun onCreate() {
}

override fun onUpgrade(oldVersion: Int, newVersion: Int) {
}
})


这样我们就将监听数据库创建和升级这部分内容也快速介绍完了,接下来即将进入到本篇文章的重头戏内容。


从上述文章中我们都可以看出,Kotlin 版的代码普遍都是比 Java 代码要更简约的,Google 给出的官方统计是,使用 Kotlin 开发可以减少大约 25% 以上的代码。


但是处处讲究简约的 Kotlin,却在有一处用法上让我着实很难受。比如使用 Java 查询 song 表中 id 为 1 的这条记录是这样写的:


Song song = LitePal.find(Song.class, 1);


而同样的功能在 Kotlin 中却需要这样写:


val song = LitePal.find(Song::class.java, 1)


由于 LitePal 必须知道要查询哪个表当中的数据,因此一定要传递一个 Class 参数给 LitePal 才行。在 Java 中我们只需要传入Song.class即可,但是在 Kotlin 中的写法却变成了Song::class.java,反而比 Java 代码更长了,有没有觉得很难受?


当然,很多人写着写着也就习惯了,这并不是什么大问题。但是随着我深入学习 Kotlin 之后,我发现 Kotlin 提供了一个相当强大的机制可以优化这个问题,这个机制叫作泛型实化。接下来我会对泛型实化的概念和用法做个详细的讲解。


要理解泛型实化,首先你需要知道泛型擦除的概念。


不管是 Java 还是 Kotlin,只要是基于 JVM 的语言,泛型基本都是通过类型擦除来实现的。也就是说泛型对于类型的约束只在编译时期存在,运行时期是无法直接对泛型的类型进行检查的。例如,我们创建一个List<String>集合,虽然在编译时期只能向集合中添加字符串类型的元素,但是在运行时期 JVM 却并不能知道它本来只打算包含哪种类型的元素,只能识别出来它是个List


Java 的泛型擦除机制,使得我们不可能使用if (a instanceof T),或者是T.class这样的语法。


而 Kotlin 也是基于 JVM 的语言,因此 Kotlin 的泛型在运行时也是会被擦除的。但是 Kotlin 中提供了一个内联函数的概念,内联函数中的代码会在编译的时候自动被替换到调用它的地方,这就使得原有方法调用时的形参声明和实参传递,在编译之后直接变成了同一个方法内的变量调用。这样的话也就不存在什么泛型擦除的问题了,因为 Kotlin 在编译之后会直接使用实参替代内联方法中泛型部分的代码。


简单点来说,就是 Kotlin 是允许将内联方法中的泛型进行实化的。


泛型实化


那么具体该怎么写才能将泛型实化呢?首先,该方法必须是内联方法才行,也就是要用inline关键字来修饰该方法。其次,在声明泛型的地方还必须加上reified关键字来表示该泛型要进行实化。示例代码如下所示:


inline fun <reified T> instanceOf(value: Any) {

}


上述方法中的泛型 T 就是一个被实化的泛型,因为它满足了内联函数和reified关键字这两个前提条件。那么借助泛型实化,我们到底可以实现什么样的效果呢?从方法名上就可以看出来了,这里我们借助泛型来实现一个 instanceOf 的效果,代码如下所示:


inline fun <reified T> instanceOf(value: Any) = value is T


虽然只有一行代码,但是这里实现了一个 Java 中完全不可能实现的功能 —— 判断参数的类型是不是属于泛型的类型。这就是泛型实化不可思议的地方。


那么我们如何使用这个方法呢?在 Kotlin 中可以这么写:


val result1 = instanceOf<String>("hello")
val result2 = instanceOf<String>(123)



可以看到,第一行代码指定的泛型是String,参数是字符串"hello",因此最后的结果是true。而第二行代码指定泛型是String,参数却是数字123,因此最后的结果是false


除了可以做类型判断之外,我们还可以直接获取到泛型的 Class 类型。看一下下面的代码:


inline fun <reified T> genericClass() = T::class.java


这段代码就更加不可思议了,genericClass()方法直接返回了当前指定泛型的 class 类型。T.class这样的语法在 Java 中是不可能的,而在 Kotlin 中借助泛型实化功能就可以使用T::class.java这样的语法了。


然后我们就可以这样调用:


val result1 = genericClass<String>()
val result2 = genericClass<Int>()



可以看到,我们如果指定了泛型String,那么最终就可以得到java.lang.String的 Class,如果指定了泛型Int,最终就可以得到java.lang.Integer的 Class。


关于 Kotlin 泛型实化这部分的讲解就到这里,现在我们重新回到 LitePal 上面。讲了这么多泛型实化方面的内容,那么 LitePal 到底如何才能利用这个特性进行优化呢?


回顾一下,刚才我们查询 song 表中 id 为 1 的这条记录是这样写的:


val song = LitePal.find(Song::class.java, 1)


这里需要传入Song::class.java是因为要告知 LitePal 去查询 song 这张表中的数据。而通过刚才泛型实化部分的讲解,我们知道 Kotlin 中是可以使用T::class.java这样的语法的,因此我在 LitePal 3.0.0 中扩展了这部分特性,允许通过指定泛型来声明查询哪张表中的内容。于是代码就可以优化成这个样子了:


val song = LitePal.find<Song>(1)


怎么样,有没有觉得代码瞬间清爽了很多?看起来比 Java 版的查询还要更加简约。


另外得益于 Kotlin 出色的类型推导机制,我们还可以将代码改为如下写法:


val song: Song? = LitePal.find(1)


这两种写法效果是一模一样的,因为如果我在song变量的后面声明了Song?类型,那么find()方法就可以自动推导出泛型类型,从而不需要再手动进行<Song>的泛型指定了。


除了find()方法之外,我还对 LitePal 中几乎全部的公有 API 都进行了优化,只要是原来需要传递 Class 参数的接口,我都增加了一个通过指定泛型来替代 Class 参数的扩展方法。注意,这里我使用的是扩展方法,而不是修改了原有方法,这样的话两种写法你都可以使用,全凭自己的喜好,如果是直接修改原有方法,那么项目升级之后就可能会造成大面积报错了,这是谁都不想看到的。


那么这里我再向大家演示另外几种 CRUD 操作优化之后的用法吧,比如我想使用 where 条件查询的时候就可以这样写:


val list = LitePal.where("duration > ?", "100").find<Song>()


这里在最后的 find() 方法中指定了泛型<Song>,得到的结果会是一个List<Song>集合。


想要删除 song 表中 id 为 1 的这条数据可以这么写:


LitePal.delete<Song>(1)


想要统计 song 表中的记录数量可以这么写:


val count = LitePal.count<Song>()


其他一些方法的优化也都是类似的,相信大家完全可以举一反三,就不再一一演示了。


这样我们就将 LitePal 新版本中的主要功能都介绍完了。当然,除了这些新功能之外,我还修复了一些已知的 bug,提升了整体框架的稳定性,如果这些正是你所需要的话,那就赶快升级吧。


收起阅读 »

从精准化测试看ASM在Android中的强势插入-JaCoco初探

ASM
在Java技术栈上,基本上提到覆盖率,大家就会想到JaCoco「Java Code Coverage的缩写」,几乎所有的覆盖率项目,都是使用JaCoco,可想而知它的影响力有多大,我们在Android项目中,也集成了JaCoco,官网文档如下。 docs.g...
继续阅读 »

在Java技术栈上,基本上提到覆盖率,大家就会想到JaCoco「Java Code Coverage的缩写」,几乎所有的覆盖率项目,都是使用JaCoco,可想而知它的影响力有多大,我们在Android项目中,也集成了JaCoco,官网文档如下。


docs.gradle.org/current/use…


但是这里的JaCoco是与单元测试配合使用的,与一般的业务测试场景不太一样,所以,我们需要自己依赖JaCoco来做改造。


初探


官网镇楼


http://www.eclemma.org/jacoco/


从官网上就能看出这是一个极具历史感的项目。最后生成的覆盖率文件,是在 源代码的基础上,用颜色标记不同的执行状态。


image-20210716171811946


在上面这张图中,绿色代表已执行, 红色代表未执行, 黄色代表执行了一部分,这样就可以算出代码的覆盖率数据。


使用全量报表


JaCoco默认的插桩方式是全部插桩,在Android项目中,要使用JaCoco的全量报表功能非常简单,因为JaCoco插件已经集成在Gradle中了,所以我们只需要开启JaCoco即可。


首先,在根目录gradle文件中加入JaCoco的依赖


classpath "org.jacoco:org.jacoco.core:0.8.4"

然后在App的gradle文件中增加插件的依赖。


apply plugin: 'jacoco'

并在android标签中,增加开关。


testCoverageEnabled = true

接下来引入JaCoco的Report模块,同时exclude掉core,因为其在gradle中已经有依赖了。


implementation('org.jacoco:org.jacoco.report:0.8.4') {
exclude group: 'org.jacoco', module: 'org.jacoco.core'
}

创建生成Report的Task


def coverageSourceDirs = ['../xxxx/src/main/java']

task jacocoTestReport(type: JacocoReport) {
group = "Reporting"
description = "Generate Jacoco coverage reports after running tests."
reports {
xml.enabled = true
html.enabled = true
}
classDirectories.setFrom(fileTree(
dir: './build/intermediates/javac/xxxxx',
excludes: ['**/R*.class']))
sourceDirectories.setFrom(files(coverageSourceDirs))
executionData.setFrom(files("$buildDir/outputs/code-coverage/connected/coverage.exec"))
doFirst {new File("$buildDir/intermediates/javac/masterDebug/classes/com/qidian/QDReader").eachFileRecurse { file ->
if (file.name.contains('$$')) {
file.renameTo(file.path.replace('$$', '$'))
}
}
}
}

在项目中合适的地方来调用这两个方法,分别用来创建JaCoco的Exec文件和写入Exec文件。


private void createExecFile() {
String DEFAULT_COVERAGE_FILE_PATH = "/mnt/sdcard/" + getPackageName();
String DEFAULT_COVERAGE_FILE = DEFAULT_COVERAGE_FILE_PATH + "/coverage.ec";
File file_path = new File(DEFAULT_COVERAGE_FILE_PATH);
File file = new File(DEFAULT_COVERAGE_FILE);
Log.d(TAG, "file_path = " + file_path);
if (!file.exists()) {
try {
file_path.mkdirs();
file.createNewFile();
} catch (IOException e) {
e.printStackTrace();
}
}
}

private void writeExecFile() {
OutputStream out = null;
try {
out = new FileOutputStream("/mnt/sdcard/" + getPackageName() + "/coverage.ec", true);
Object agent = Class.forName("org.jacoco.agent.rt.RT")
.getMethod("getAgent")
.invoke(null);
out.write((byte[]) agent.getClass().getMethod("getExecutionData", boolean.class)
.invoke(agent, false));
} catch (Exception e) {
Log.d(TAG, e.toString(), e);
e.printStackTrace();
} finally {
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

在创建Exec文件后,进行测试,然后写入Exec文件,等测试完毕后,把生成的Exec文件通过ADB pull到本地,再执行jacocoTestReport这个Task即可生成全量的JaCoco覆盖率报告。


花了这么长时间写了这么多,其实并没什么卵用,只是让大家看下如何来使用JaCoco的标准用法。


JaCoco插桩原理


JaCoco在Android上只能使用Offline mode,它的实现机制其实很简单,我们反编译一下它插入的代码。


image-20210617135224018


可以发现,实际上JaCoco就是用一个Boolean数组来标记每句可执行代码,只要执行过相应的语句,当前位就被标记为True,这个标记,官方称之为「探针」(Probe)。


JaCoco对代码的修改主要体现在下面几个地方:



  • 在Class中增加




    j


    a


    c


    o


    c


    o


    D


    a


    t


    a


    属性和



    jacocoData属性和


    jacocoInit方法

  • 在Method中增加了$jacocoInit数字并初始化

  • 增加了对数组的修改


当然,这只是JaCoco最基本的原理,实际的实现细节会更加复杂,例如条件、选择语句、方法函数的探针插入等等,这里不详细深入讨论,感兴趣的朋友可以参考JaCoco的源码:


github.com/jacoco/jaco…


性能影响


由于JaCoco只是插入一个探针数组,所以对代码执行的性能开销影响不大,但是由于插入大量的探针代码,所以代码体积会增大不少,一般情况下,Android会在测试包中做插入,而在正式包中去除插入逻辑。



当然,借助JaCoco还能玩一些骚操作,比如发到线上,实时统计代码中有哪些代码从未执行过,用于发现潜在的垃圾代码。



探针插桩策略


JaCoco的核心逻辑就是要决定,到底在哪插入探针代码。官网文档上对插桩策略写的比较清楚,涉及到字节码的一些原理,所以这里就不深入讲解了,感兴趣的朋友可以通过下面的链接查看。


http://www.jacoco.org/jacoco/trun…


关键代码类


JaCoco对代码的探针插入分析,主要是利用了下面这些计数器:



  • 指令计数器(CounterImpl)

  • 行计数器(LineImpl)

  • 方法计算节点(MethodCoverageImpl)

  • 类计算节点(ClassCoverageImpl)

  • Package计算节点(PackageCoverageImpl)

  • Module计算节点(BundleCoverageImpl)


这里面包含了JaCoco的覆盖率数据。


JaCoco的使用其实非常简单,原理也很简单,但要做的好,稳定运行这么多年没有Bug,还是很难的,所以现在市面上做覆盖率的很多软件都逐渐被历史所淘汰了,而剩下的就是经历过时间检验的真金。

收起阅读 »