注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

前端面试知识点(四)

9、ES6 Module 相对于 CommonJS 的优势是什么?温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 No...
继续阅读 »

9、ES6 Module 相对于 CommonJS 的优势是什么?

温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 16.8.2 Static module structure 。除此之外,以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。

关于 ES Module 和 CommonJS 的规范以及语法,这里不再详细叙述,如果你还不了解这两者的语法糖,可以查看 ECMAScript 6 入门 / Module 语法ES Module 标准以及 Node.js 的 CommonJS 模块,两者的主要区别如下所示:

类型ES ModuleCommonJS
加载方式编译时运行时
引入性质引用 / 只读浅拷贝 / 可读写
模块作用域thisthis / __filename / __dirname...

9.1 加载方式

加载方式是 ES Module 和 CommonJS 的最主要区别,这使得两者在编译时运行时上各有优劣。首先来看一下 ES Module 在加载方式上的特性,如下所示:

// 编译时:VS Code 鼠标 hover 到 b 时可以显示出 b 的类型信息
import { b } from './b';

const a = 1;
// WARNING: 具有逻辑
if(a === 1) {
// 编译时:ESLint: Parsing error: 'import' and 'export' may only appear at the top level
// 运行时:SyntaxError: Unexpected token '{'
// TIPS: 这里可以使用 import() 进行动态导入
import { b } from './b';
}

const c = 'b';
// WARNING: 含有变量
// 编译时:ESLint:Parsing error: Unexpected token `
// 运行时:SyntaxError: Unexpected template string
import { d } from `./${c}`;

CommonJS 相对于 ES Module 在加载方式上的特性如下所示:

const a = 1;

if(a === 1) {
// VS Code 鼠标 hover 到 b 时,无法显示出 b 的类型信息
const b = require('./b');
}

const c = 'b';
const d = require(`./${c}`);

大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。在前端知识点扫盲(一)/ 编译器原理中重点讲解了整个编译器的执行阶段,如下图所示: image.png ES Module 是采用静态的加载方式,也就是模块中导入导出的依赖关系可以在代码编译时就确定下来。如上图所示,代码在编译的过程中可以做的事情包含词法和语法分析、类型检查以及代码优化等等。因此采用 ES Module 进行代码设计时可以在编译时通过 ESLint 快速定位出模块的词法语法错误以及类型信息等。ES Module 中会产生一些错误的加载方式,是因为这些加载方式含有逻辑和变量的运行时判断,只有在代码的运行时阶段才能确定导入导出的依赖关系,这明显和 ES Module 的加载机制不相符。

CommonJS 相对于 ES Module 在加载模块的方式上存在明显差异,是因为 CommonJS 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。

温馨提示:注意 import 语法和 import() 的区别,import() 是 tc39 中的一种提案,该提案允许你可以使用类似于 import(`${path}/foo.js`) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的懒加载。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。

9.2 编译优化

由于 ES Module 是在编译时就能确定模块之间的依赖关系,因此可以在编译的过程中进行代码优化。例如:

// hello.js 
export function a() {
console.log('a');
}

export function b() {
console.log('b');
}

// index.js
// TIPS: Webpack 编译入口文件
// 这里不引入 function b
import { a } from './hello';
console.log(a);

使用 Webpack 5.47.1 (Webpack Cli 4.7.2)进行代码编译,生成的编译产物如下所示:

(()=>{"use strict";console.log((function(){console.log("a")}))})();

可以发现编译生成的产物没有 function b 的代码,这是在编译阶段对代码进行了优化,移除了未使用的代码(Dead Code),这种优化的术语被叫做 Tree Shaking

温馨提示:你可以将应用程序想象成一棵树。绿色表示实际用到的 Source Code(源码)和 Library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。

温馨提示:在 ES Module 中可能会因为代码具有副作用(例如操作原型方法以及添加全局对象的属性等)导致优化失败,如果想深入了解 Tree Shaking 的更多优化注意事项,可以深入阅读你的 Tree-Shaking 并没什么卵用

为了对比 ES Module 的编译优化能力,同样采用 CommonJS 规范进行模块导入:

// hello.js
exports.a = function () {
console.log('a');
};

exports.b = function () {
console.log('b');
};

// index.js
// TIPS: Webpack 编译入口文件
const { a } = require('./hello');
console.log(a);

使用 Webpack 进行代码编译,生成的编译产物如下所示:

(() => {
var o = {
418: (o, n) => {
(n.a = function () {
console.log('a');
}),
// function b 的代码并没有被去除
(n.b = function () {
console.log('b');
});
},
},
n = {};
function r(t) {
var e = n[t];
if (void 0 !== e) return e.exports;
var s = (n[t] = { exports: {} });
return o[t](s, s.exports, r), s.exports;
}
(() => {
const { a: o } = r(418);
console.log(o);
})();
})();

可以发现在 CommonJS 模块中,尽管没有使用 function b,但是代码仍然会被打包编译,正是因为 CommonJS 模块只有在运行时才能进行同步导入,因此无法在编译时确定是否 function b 是一个 Dead Code。

温馨提示:在 Node.js 环境中一般不需要编译 CommonJS 模块代码,除非你使用了当前 Node 版本所不能兼容的一些新语法特性。

大家可能会注意到一个新的问题,当我们在制作工具库或者组件库的时候,通常会将库包编译成 ES5 语法,这样尽管 Babel 以及 Webpack 默认会忽略 node_modules 里的模块,我们的项目在编译时引入的这些模块仍然能够做到兼容。在这个过程中,如果你制作的库包体积非常大,你又不提供非常细粒度的按需引入的加载方式,那么你可以编译你的源码使得编译产物可以支持 ES Module 的导入导出模式(注意只支持 ES6 中模块的语法,其他的语法仍然需要被编译成 ES5),当项目真正引入这些库包时可以通过 Tree Shaking 的特性在编译时去除未引入的代码(Dead Code)。

温馨提示:如果你想了解如何使发布的 Npm 库包支持 Tree Shaking 特性,可以查看 defense-of-dot-js / Typical Usage、 Webpack / Final Stepspgk.module 以及 rollup.js / Tree Shaki…

Webpack 对于 module 字段的支持的描述提示:The module property should point to a script that utilizes ES2015 module syntax but no other syntax features that aren't yet supported by browsers or node. This enables webpack to parse the module syntax itself, allowing for lighter bundles via tree shaking if users are only consuming certain parts of the library.

9.3 加载原理 & 引入性质

温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules

在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示: 05_module_record-768x441.png

事实上, ES Module 的加载过程主要分为如下三个阶段:

  • 构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。
  • 实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。
  • 运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。

温馨提示:import 的上述三个阶段其实在 import() 中体现的更加直观(尽管 import 已经被多数浏览器支持,但是我们在真正开发和运行的过程中仍然会使用编译后的代码运行,而不是采用浏览器 script 标签的远程地址的动态异步加载方式),而 import() 事实上如果要实现懒加载优化(例如 Vue 里的路由懒加载,更多的是在浏览器的宿主环境而不是 Node.js 环境,这里不展开更多编译后实现方式的细节问题),大概率要完整经历上述三个阶段的异步加载过程,具体再次查看 tc39 动态提案:This proposal adds an import(specifier) syntactic form, which acts in many ways like a function (but see below). It returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module's dependencies, as well as the module itself.

07_3_phases.png ES Module 模块加载的三个阶段分别需要在编译时和运行时进行(可能有的同学会像我一样好奇实例化阶段到底是在编译时还是运行时进行,根据 tc39 动态加载提案里的描述可以得出你想要的答案:The existing syntactic forms for importing modules are static declarations. They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime "linking" process.),而 CommonJS 规范中的模块是在运行时同步顺序执行,模块在加载的过程中不会被中断,具体如下图所示: 43_cjs_cycle.png 上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 ES Module 在浏览器(注意这里说的是浏览器)中运行的时候需要下载文件然后才能进行实例化和运行,如果这个过程是同步进行,那么会影响页面的加载性能。

从 ES Module 链接的过程可以发现模块之间的引用关系是内存的地址引用,如下所示:

// hello.js
export let a = 1;

setTimeout(() => {
a++;
}, 1000);


// index.js
import { a } from './hello.js';

setTimeout(() => {
console.log(a); // 2
}, 2000);

在 Node (v14.15.4)环境中运行上述代码得到的执行结果是 2,对比一下 CommonJS 规范的执行:

// hello.js
exports.a = 1;

setTimeout(() => {
exports.a++;
}, 1000);


// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a); // 1
}, 2000);

可以发现打印的结果信息和 ES Module 的结果不一样,这里的执行结果为 1。产生上述差异的根本原因是实例化的方式不同,如下图所示:1665647773-5acd908e6e76f_fix732.png

在 ES Module 的导出中 Module Record 会实时跟踪(wire up 在这里理解为链接或者引用的意思)和绑定每一个导出变量对应的内存地址(从上图可以发现值还没有被填充,而 function 则可以在链接阶段进行初始化),导入同样对应的是导出所对应的同一个内存地址,因此对导入变量进行处理其实处理的是同一个引用地址的数据,如下图所示:

1181374600-5acd91c0798bf_fix732.png

CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示:

516296747-5acd92fbbb9e6_fix732.png

在上述代码执行的过程中先对变量 a 进行值拷贝,因此尽管设置了定时器,变量 a 被引入后打印的信息仍然是 1。需要注意的是这种拷贝是浅拷贝,如下所示:

// hello.js
exports.a = {
value: 1,
};

setTimeout(() => {
exports.a.value++;
}, 1000);

// index.js
let { a } = require('./hello');

setTimeout(() => {
console.log(a.value); // 2
}, 2000);

接下来对比编译后的差异,将 ES Module 的源码进行编译(仍然使用 Webpack),编译之后的代码如下所示:

(() => {
'use strict';
let e = 1;
setTimeout(() => {
e++;
}, 1e3),
setTimeout(() => {
console.log(e);
}, 2e3);
})();

可以看出,将 ES Module 的代码进行编译后,使用的是同一个变量值,此时将 CommonJS 的代码进行编译:

(() => {
var e = {
418: (e, t) => {
// hello.js 中的模块代码
(t.a = 1),
setTimeout(() => {
t.a++;
}, 1e3);
},
},
t = {};
function o(r) {
// 开辟模块的缓存空间
var s = t[r];
// 获取缓存信息,每次返回相同的模块对象信息
if (void 0 !== s) return s.exports;
// 开辟模块对象的内存空间
var a = (t[r] = { exports: {} });
// 逗号运算符,先运行模块代码,赋值模块对象的值,然后返回模块信息
// 由于缓存,模块代码只会被执行一次
return e[r](a, a.exports, o), a.exports;
}
(() => {
// 浅拷贝
let { a: e } = o(418);
setTimeout(() => {
// 尽管 t.a ++,这里输出的仍然是 1
console.log(e);
}, 2e3);
})();
})();

可以发现 CommonJS 规范在编译后会缓存模块的信息,从而使得下一次将从缓存中直接获取模块数据。除此之外,缓存会使得模块代码只会被执行一次。查看 Node.js 官方文档对于 CommonJS 规范的缓存描述,发现 Webpack 的编译完全符合 CommonJS 规范的缓存机制。了解了这个机制以后,你会发现多次使用 require 进行模块加载不会导致代码被执行多次,这是解决无限循环依赖的一个重要特征。

除了引入的方式可能会有区别之外,引入的代码可能还存在一些区别,比如在 ES Module 中:

// hello.js
export function a() {
console.log('a this: ', this);
}


// index.js
import { a } from './hello.js';

// a = 1;
^
// TypeError: Assignment to constant variable.
// ...
// at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
// at async Loader.import (internal/modules/esm/loader.js:166:24)
// at async Object.loadESM (internal/process/esm_loader.js:68:5)
a = 1;

使用 Node.js 直接运行上述 ES Module 代码,是会产生报错的,因为导入的变量根据提示可以看出是只读变量,而如果采用 Webpack 进行编译后运行,则没有上述问题,除此之外 CommonJS 中导入的变量则可读可写。当然除此之外,你也可以尝试更多的其他方面,比如:

// hello.js

// 非严格模式
b = 1;

export function a() {
console.log('a this: ', this);
}

// index.js
import { a } from './hello.js';

console.log('a: ', a);

你会发现使用 Node.js 环境执行上述 ES Module 代码,会直接抛出下述错误信息:

ReferenceError: b is not defined
at file:///Users/ziyi/Desktop/Gitlab/Explore/module-example/esmodule/hello.js:1:3
at ModuleJob.run (internal/modules/esm/module_job.js:152:23)
at async Loader.import (internal/modules/esm/loader.js:166:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)

是因为 ES Module 的模块需要运行在严格模式下, 而 CommonJS 规范则没有这样的要求,如果你在仔细一点观察的话,会发现使用 Webpack 进行编译的时候,ES Module 编译的代码会在前面加上 "use strict",而 CommonJS 编译的代码没有。

9.4 模块作用域

大家会发现在 Node.js 的模块中设计代码时可以使用诸如 __dirname、__filename 之类的变量(需要注意在 Webpack 编译出的 CommonJS 前端产物中,并没有 __filename、__dirname 等变量信息,浏览器中并不需要这些文件系统的变量信息),是因为 Node.js 在加载模块时会对其进行如下包装:

// https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];

索性看到这个模块作用域的代码,我们就继续查看一下 require 的源码:

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L997
Module.prototype.require = function(id) {
validateString(id, 'id');
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id,
'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};

// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L757

// Check the cache for the requested file.
// 1. If a module already exists in the cache: return its exports object.
// 2. If the module is native: call
// `NativeModule.prototype.compileForPublicLoader()` and return the exports.
// 3. Otherwise, create a new module for the file and save it to the cache.
// Then have it load the file contents before returning its exports
// object.
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
// Fast path for (lazy loaded) modules in the same directory. The indirect
// caching is required to allow cache invalidation without changing the old
// cache key names.
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
const filename = relativeResolveCache[relResolveCacheIdentifier];
// 有缓存,则走缓存
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}

// `node:` 用于检测核心模块,例如 fs、path 等
// Node.js 文档:http://nodejs.cn/api/modules.html#modules_core_modules
// 这里主要用于绕过 require 缓存
const filename = Module._resolveFilename(request, parent, isMain);
if (StringPrototypeStartsWith(filename, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(filename, 5);

const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}

return module.exports;
}

// 缓存处理
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}

const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers) return mod.exports;

// Don't call updateChildren(), Module constructor already does.
const module = cachedModule || new Module(filename, parent);

if (isMain) {
process.mainModule = module;
module.id = '.';
}

Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
}

let threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
delete relativeResolveCache[relResolveCacheIdentifier];
const children = parent?.children;
if (ArrayIsArray(children)) {
const index = ArrayPrototypeIndexOf(children, module);
if (index !== -1) {
ArrayPrototypeSplice(children, index, 1);
}
}
}
} else if (module.exports &&
!isProxy(module.exports) &&
ObjectGetPrototypeOf(module.exports) ===
CircularRequirePrototypeWarningProxy) {
ObjectSetPrototypeOf(module.exports, ObjectPrototype);
}
}

return module.exports;
};

温馨提示:这里没有将 wrapper 和 _load 的联系说清楚(最后如何在 _load 中执行 wrapper),大家可以在 Node.js 源码中跟踪一下看一下上述代码是怎么被执行的,是否是 eval 呢?不说了,脑壳疼,想要了解更多信息,可以查看 Node.js / vm。除此之外,感兴趣的同学也了解一下 import 语法在 Node.js 中的底层实现,这里脑壳疼,就没有深入研究了。

温馨提示的温馨提示:比如你在源码中找不到上述代码的执行链路,那最简单的方式就是引入一个错误的模块,让错误信息将错误栈抛出来,比如如下所示,你会发现最底下执行了 wrapSafe,好了你又可以开始探索了,因为你对 safe 这样的字眼一定感到好奇,底下是不是执行的时候用了沙箱隔离呢?

SyntaxError: Cannot use import statement outside a module
at wrapSafe (internal/modules/cjs/loader.js:979:16)
at Module._compile (internal/modules/cjs/loader.js:1027:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
at Module.load (internal/modules/cjs/loader.js:928:32)
at Function.Module._load (internal/modules/cjs/loader.js:769:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
at internal/main/run_main_module.js:17:47

温馨提示:是不是以前经常有面试官询问 exports 和 module.exports 有什么关联,其实根本不用纠结这个问题,因为两者指向的是同一个引用地址,你如果对 exports 进行重新赋值,那么引用发生了改变,你新引用的部分当然就不会导出了,因为从源码里可以看出,我们这里导出的是 module.exports。

接下来主要是重点看下 this 执行上下文的差异(注意这里只测试 Node.js 环境,编译后的代码可能会有差异),首先执行 ES Module 模块的代码:

// hello.js
export function a() {
console.log('this: ', this); // undefined
}

// index.js
import { a } from './hello.js';
a();

我们接着执行 CommonJS 的代码:

// hello.js
exports.a = function () {
console.log('this: ', this);
};

// index.js
let { a } = require('./hello');
a();

你会发现 this 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究:

image.png

温馨提示:Node.js 的调试还能在浏览器进行?可以查看一下 Node.js 调试,当然你也可以使用 VS Code 进行调试,需要进行一些额外的 launch 配置,当然如果你觉得 Node.js 自带的浏览器调试方式太难受了,也可以想想办法,如何通过 IP 端口在浏览器中进行调试,并且可以做到代码变动监听调试。

大家可以不用太纠结代码的细致实现,只需要大致可以了解到 CommonJS 中模块的导入过程即可,事实上 Webpack 编译的结果大致可以理解为该代码的浏览器简易版。那还记得我之前在面试分享中的题目:两年工作经验成功面试阿里P6总结 / 如何在Node端配置路径别名(类似于Webpack中的alias配置),如果你阅读了上述源码,基本上思路就是 HACK 原型链上的 require 方法:

const Module = require('module');
const originalRequire = Module.prototype.require;

Module.prototype.require = function(id){
// 这里加入 path 的逻辑
return originalRequire.apply(this, id);
};

小结

目前的面试题答案系列稍微有些混乱,后续可能会根据类目对面试题进行简单分类,从而整理出更加体系化的答案。本篇旨在希望大家可以对面试题进行举一反三,从而加深理解(当我们问出一个问题的时候,可以衍生出 N 个问题)。


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

收起阅读 »

前端面试知识点(三)

6、简单描述一下 Babel 的编译过程? Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。 温馨提示:如果某种高...
继续阅读 »

6、简单描述一下 Babel 的编译过程?


Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。



温馨提示:如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。



image.png


从上图可知,Babel 的编译过程主要可以分为三个阶段:



  • 解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。

  • 转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。

  • 生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。


具体的流程如下所示:
image.png


举个栗子,如果要将 TypeScript 语法转换成 ES5 语法:


// 源代码
let a: string = 1;
// 目标代码
var a = 1;

6.1 解析(Parser)


Babel 的解析过程(源码到 AST 的转换)可以使用 @babel/parser,它的主要特点如下:



  • 支持解析最新的 ES2020

  • 支持解析 JSX、Flow & TypeScript

  • 支持解析实验性的语法提案(支持任何 Stage 0 的 PRS)


@babel/parser 主要是基于输入的字符串流(源代码)进行解析,最后转换成规范(基于 ESTree 进行调整)的 AST,如下所示:


import { parse } from '@babel/parser';
const source = `let a: string = 1;`;

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是支持解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

需要注意,在 Parser 阶段主要是进行词法和语法分析,如果词法或者语法分析错误,那么会在该阶段被检测出来。如果检测正确,则可以进入语法的转换阶段。


6.2 转换(Transform)


Babel 的转换过程(AST 到 AST 的转换)主要使用 @babel/traverse,该库包可以通过访问者模式自动遍历并访问 AST 树的每一个 Node 节点信息,从而实现节点的替换、移除和添加操作,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}

const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问变量声明标识符
VariableDeclaration(path) {
// 将 const 和 let 转换为 var
path.node.kind = 'var';
},
// 访问 TypeScript 类型声明标识符
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

关于 Babel 中的访问器 API,这里不再过多说明,如果想了解更多信息,可以查看 Babel 插件手册。除此之外,你可能已经注意到这里的转换逻辑其实可以理解为实现一个简单的 Babel 插件,只是没有封装成 Npm 包。当然,在真正的插件开发开发中,还可以配合 @babel/types 工具包进行节点信息的判断处理。



温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 babel-plugin-transform-block-scoping



6.3 生成(Generate)


Babel 的代码生成过程(AST 到目标代码的转换)主要使用 @babel/generator,如下所示:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

enum ParseSourceTypeEnum {
Module = 'module',
Script = 'script',
Unambiguous = 'unambiguous',
}

enum ParsePluginEnum {
Flow = 'flow',
FlowComments = 'flowComments',
TypeScript = 'typescript',
Jsx = 'jsx',
V8intrinsic = 'v8intrinsic',
}
const source = `let a: string = 1;`;

// 解析(Parser)阶段
const ast = parse(source, {
// 严格模式下解析并且允许模块定义
sourceType: ParseSourceTypeEnum.Module,
// 支持解析 TypeScript 语法(注意,这里只是可以解析,并不是转换 TypeScript)
plugins: [ParsePluginEnum.TypeScript],
});

// 转换(Transform) 阶段
traverse(ast, {
// 访问词法规则
VariableDeclaration(path) {
path.node.kind = 'var';
},

// 访问词法规则
TSTypeAnnotation(path) {
// 移除 TypeScript 的声明类型
path.remove();
},
});

// 生成(Generate)阶段
const { code } = generate(ast);
// code: var a = 1;
console.log('code: ', code);

如果你想了解上述输入源对应的 AST 数据或者尝试自己编译,可以使用工具 AST Explorer (也可以使用 Babel 官网自带的 Try It Out ),具体如下所示:


image.png



温馨提示:上述第三个框是以插件的 API 形式进行调用,如果想了解 Babel 的插件开发,可以查看 Babel 插件手册 / 编写你的第一个 Babel 插件



如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 antlr4ts(可以配合交叉编译工具链 riscv-gnu-toolchain,gcc编译工具的制作还是非常耗时的)。



阅读链接: Babel 用户手册Babel 插件手册






收起阅读 »

前端面试知识点(二)

语法 22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应? 23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip? 24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)? The&n...
继续阅读 »

语法


22、如何实现一个上中下三行布局,顶部和底部最小高度是 100px,中间自适应?


23、如何判断一个元素 CSS 样式溢出,从而可以选择性的加 title 或者 Tooltip?


24、如何让 CSS 元素左侧自动溢出(... 溢出在左侧)?


The direction CSS property sets the direction of text, table columns, and horizontal overflow. Use rtl for languages written from right to left (like Hebrew or Arabic), and ltr for those written from left to right (like English and most other languages).


具体查看:developer.mozilla.org/en-US/docs/…


25、什么是沙箱?浏览器的沙箱有什么作用?


26、如何处理浏览器中表单项的密码自动填充问题?


27、Hash 和 History 路由的区别和优缺点?


28、JavaScript 中对象的属性描述符有哪些?分别有什么作用?


29、JavaScript 中 console 有哪些 api ?


The console object provides access to the browser's debugging console (e.g. the Web console in Firefox). The specifics of how it works varies from browser to browser, but there is a de facto set of features that are typically provided.


这里列出一些我常用的 API:



  • console.log

  • console.error

  • console.time

  • console.timeEnd

  • console.group


具体查看:developer.mozilla.org/en-US/docs/…


30、 简单对比一下 Callback、Promise、Generator、Async 几个异步 API 的优劣?


在 JavaScript 中利用事件循环机制(Event Loop)可以在单线程中实现非阻塞式、异步的操作。例如



我们重点来看一下常用的几种编程方式(Callback、Promise、Generator、Async)在语法糖上带来的优劣对比。


Callback


Callback(回调函数)是在 Web 前端开发中经常会使用的编程方式。这里举一个常用的定时器示例:


export interface IObj {
value: string;
deferExec(): void;
deferExecAnonymous(): void;
console(): void;
}

export const obj: IObj = {
value: 'hello',

deferExecBind() {
// 使用箭头函数可达到一样的效果
setTimeout(this.console.bind(this), 1000);
},

deferExec() {
setTimeout(this.console, 1000);
},

console() {
console.log(this.value);
},
};

obj.deferExecBind(); // hello
obj.deferExec(); // undefined

回调函数经常会因为调用环境的变化而导致 this 的指向性变化。除此之外,使用回调函数来处理多个继发的异步任务时容易导致回调地狱(Callback Hell):


fs.readFile(fileA, 'utf-8', function (err, data) {
fs.readFile(fileB, 'utf-8', function (err, data) {
fs.readFile(fileC, 'utf-8', function (err, data) {
fs.readFile(fileD, 'utf-8', function (err, data) {
// 假设在业务中 fileD 的读写依次依赖 fileA、fileB 和 fileC
// 或者经常也可以在业务中看到多个 HTTP 请求的操作有前后依赖(继发 HTTP 请求)
// 这些异步任务之间纵向嵌套强耦合,无法进行横向复用
// 如果某个异步发生变化,那它的所有上层或下层回调可能都需要跟着变化(比如 fileA 和 fileB 的依赖关系倒置)
// 因此称这种现象为 回调地狱
// ....
});
});
});
});


回调函数不能通过 return 返回数据,比如我们希望调用带有回调参数的函数并返回异步执行的结果时,只能通过再次回调的方式进行参数传递:


// 希望延迟 3s 后执行并拿到结果
function getAsyncResult(result: number) {
setTimeout(() => {
return result * 3;
}, 1000);
}

// 尽管这是常规的编程思维方式
const result = getAsyncResult(3000);
// 但是打印 undefined
console.log('result: ', result);

function getAsyncResultWithCb(result: number, cb: (result: number) => void) {
setTimeout(() => {
cb(result * 3);
}, 1000);
}

// 通过回调的形式获取结果
getAsyncResultWithCb(3000, (result) => {
console.log('result: ', result); // 9000
});


对于 JavaScript 中标准的异步 API 可能无法通过在外部进行 try...catch... 的方式进行错误捕获: 


try {
setTimeout(() => {
// 下述是异常代码
// 你可以在回调函数的内部进行 try...catch...
console.log(a.b.c)
}, 1000)

} catch(err) {
// 这里不会执行
// 进程会被终止
console.error(err)
}

上述示例讲述的都是 JavaScript 中标准的异步 API ,如果使用一些三方的异步 API 并且提供了回调能力时,这些 API 可能是非受信的,在真正使用的时候会因为执行反转(回调函数的执行权在三方库中)导致以下一些问题:



  • 使用者的回调函数设计没有进行错误捕获,而恰恰三方库进行了错误捕获却没有抛出错误处理信息,此时使用者很难感知到自己设计的回调函数是否有错误

  • 使用者难以感知到三方库的回调时机和回调次数,这个回调函数执行的权利控制在三方库手中

  • 使用者无法更改三方库提供的回调参数,回调参数可能无法满足使用者的诉求

  • ...


举个简单的例子:


interface ILib<T> {
params: T;
emit(params: T): void;
on(callback: (params: T) => void): void;
}

// 假设以下是一个三方库,并发布成了npm 包
export const lib: ILib<string> = {
params: '',

emit(params) {
this.params = params;
},

on(callback) {
try {
// callback 回调执行权在 lib 上
// lib 库可以决定回调执行多次
callback(this.params);
callback(this.params);
callback(this.params);
// lib 库甚至可以决定回调延迟执行
// 异步执行回调函数
setTimeout(() => {
callback(this.params);
}, 3000);
} catch (err) {
// 假设 lib 库的捕获没有抛出任何异常信息
}
},
};

// 开发者引入 lib 库开始使用
lib.emit('hello');

lib.on((value) => {
// 使用者希望 on 里的回调只执行一次
// 这里的回调函数的执行时机是由三方库 lib 决定
// 实际上打印四次,并且其中一次是异步执行
console.log(value);
});

lib.on((value) => {
// 下述是异常代码
// 但是执行下述代码不会抛出任何异常信息
// 开发者无法感知自己的代码设计错误
console.log(value.a.b.c)
});

Promise


Callback 的异步操作形式除了会造成回调地狱,还会造成难以测试的问题。ES6 中的 Promise (基于 Promise A + 规范的异步编程解决方案)利用有限状态机的原理来解决异步的处理问题,Promise 对象提供了统一的异步编程 API,它的特点如下:



  • Promise 对象的执行状态不受外界影响。Promise 对象的异步操作有三种状态: pending(进行中)、 fulfilled(已成功)和 rejected(已失败) ,只有 Promise 对象本身的异步操作结果可以决定当前的执行状态,任何其他的操作无法改变状态的结果

  • Promise 对象的执行状态不可变。Promise 的状态只有两种变化可能:从 pending(进行中)变为 fulfilled(已成功)或从 pending(进行中)变为 rejected(已失败)



温馨提示:有限状态机提供了一种优雅的解决方式,异步的处理本身可以通过异步状态的变化来触发相应的操作,这会比回调函数在逻辑上的处理更加合理,也可以降低代码的复杂度。



Promise 对象的执行状态不可变示例如下:


const promise = new Promise<number>((resolve, reject) => {
// 状态变更为 fulfilled 并返回结果 1 后不会再变更状态
resolve(1);
// 不会变更状态
reject(4);
});

promise
.then((result) => {
// 在 ES 6 中 Promise 的 then 回调执行是异步执行(微任务)
// 在当前 then 被调用的那轮事件循环(Event Loop)的末尾执行
console.log('result: ', result);
})
.catch((error) => {
// 不执行
console.error('error: ', error);
});

假设要实现两个继发的 HTTP 请求,第一个请求接口返回的数据是第二个请求接口的参数,使用回调函数的实现方式如下所示(这里使用 setTimeout 来指代异步请求):


// 回调地狱
const doubble = (result: number, callback: (finallResult: number) => void) => {
// Mock 第一个异步请求
setTimeout(() => {
// Mock 第二个异步请求(假设第二个请求的参数依赖第一个请求的返回结果)
setTimeout(() => {
callback(result * 2);
}, 2000);
}, 1000);
};

doubble(1000, (result) => {
console.log('result: ', result);
});


温馨提示:继发请求的依赖关系非常常见,例如人员基本信息管理系统的开发中,经常需要先展示组织树结构,并默认加载第一个组织下的人员列表信息。



如果采用 Promise 的处理方式则可以规避上述常见的回调地狱问题:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
// 将 resolve 改成 reject 会被 catch 捕获
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
return nextPromise(result);
})
.then((result) => {
// 2s 后打印 2000
console.log('result: ', result);
})
// 任何一个 Promise 到达 rejected 状态都能被 catch 捕获
.catch((err) => {
console.error('err: ', err);
});

Promise 的错误回调可以同时捕获 firstPromisenextPromise 两个函数的 rejected 状态。接下来考虑以下调用场景:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
nextPromise(result).then((result) => {
// 后打印
console.log('nextPromise result: ', result);
});
})
.then((result) => {
// 先打印
// 由于上一个 then 没有返回值,这里打印 undefined
console.log('firstPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});

首先 Promise 可以注册多个 then(放在一个执行队列里),并且这些 then 会根据上一次返回值的结果依次执行。除此之外,各个 Promise 的 then 执行互不干扰。 我们将示例进行简单的变换:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Mock 异步请求
setTimeout(() => resolve(result * 2), 1000);
});
};

firstPromise(1000)
.then((result) => {
// 返回了 nextPromise 的 then 执行后的结果
return nextPromise(result).then((result) => {
return result;
});
})
// 接着 nextPromise 的 then 执行的返回结果继续执行
.then((result) => {
// 2s 后打印 2000
console.log('nextPromise result: ', result);
})
.catch((err) => {
console.error('err: ', err);
});


上述例子中的执行结果是因为 then 的执行会返回一个新的 Promise 对象,并且如果 then 执行后返回的仍然是 Promise 对象,那么下一个 then 的链式调用会等待该 Promise 对象的状态发生变化后才会调用(能得到这个 Promise 处理的结果)。接下来重点看下 Promise 的错误处理:


const promise = new Promise<string>((resolve, reject) => {
// 下述是异常代码
console.log(a.b.c);
resolve('hello');
});

promise
.then((result) => {
console.log('result: ', result);
})
// 去掉 catch 仍然会抛出错误,但不会退出进程终止脚本执行
.catch((err) => {
// 执行
// ReferenceError: a is not defined
console.error(err);
});

setTimeout(() => {
// 继续执行
console.log('hello world!');
}, 2000);

从上述示例可以看出 Promise 的错误不会影响其他代码的执行,只会影响 Promise 内部的代码本身,因为Promise 会在内部对错误进行异常捕获,从而保证整体代码执行的稳定性。Promise 还提供了其他的一些 API 方便多任务的执行,包括



  • Promise.all:适合多个异步任务并发执行但不允许其中任何一个任务失败

  • Promise.race :适合多个异步任务抢占式执行

  • Promise.allSettled :适合多个异步任务并发执行但允许某些任务失败


Promise 相对于 Callback 对于异步的处理更加优雅,并且能力也更加强大, 但是也存在一些自身的缺点:



  • 无法取消 Promise 的执行

  • 无法在 Promise 外部通过 try...catch... 的形式进行错误捕获(Promise 内部捕获了错误)

  • 状态单一,每次决断只能产生一种状态结果,需要不停的进行链式调用



温馨提示:手写 Promise 是面试官非常喜欢的一道笔试题,本质是希望面试者能够通过底层的设计正确了解 Promise 的使用方式,如果你对 Promise 的设计原理不熟悉,可以深入了解一下或者手动设计一个。



Generator


Promise 解决了 Callback 的回调地狱问题,但也造成了代码冗余,如果一些异步任务不支持 Promise 语法,就需要进行一层 Promise 封装。Generator 将 JavaScript 的异步编程带入了一个全新的阶段,它使得异步代码的设计和执行看起来和同步代码一致。Generator 使用的简单示例如下:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

// 在 Generator 函数里执行的异步代码看起来和同步代码一致
function* gen(result: number): Generator<Promise<number>, Promise<number>, number> {
// 异步代码
const firstResult = yield firstPromise(result)
console.log('firstResult: ', firstResult) // 2
// 异步代码
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

const g = gen(1)

// 手动执行 Generator 函数
g.next().value.then((res: number) => {
// 将 firstPromise 的返回值传递给第一个 yield 表单式对应的 firstResult
return g.next(res).value
}).then((res: number) => {
// 将 nextPromise 的返回值传递给第二个 yield 表单式对应的 nextResult
return g.next(res).value
})

通过上述代码,可以看出 Generator 相对于 Promise 具有以下优势:



  • 丰富了状态类型,Generator 通过 next 可以产生不同的状态信息,也可以通过 return 结束函数的执行状态,相对于 Promise 的 resolve 不可变状态更加丰富 

  • Generator 函数内部的异步代码执行看起来和同步代码执行一致,非常利于代码的维护

  • Generator 函数内部的执行逻辑和相应的状态变化逻辑解耦,降低了代码的复杂度


next 可以不停的改变状态使得 yield 得以继续执行的代码可以变得非常有规律,例如从上述的手动执行 Generator 函数可以看出,完全可以将其封装成一个自动执行的执行器,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>, Promise<number>, number>

function* gen(): Gen {
const firstResult = yield firstPromise(1)
console.log('firstResult: ', firstResult) // 2
const nextResult = yield nextPromise(firstResult)
console.log('nextResult: ', nextResult) // 6
return nextPromise(firstResult)
}

// Generator 自动执行器
function co(gen: () => Gen) {
const g = gen()
function next(data: number) {
const result = g.next(data)
if(result.done) {
return result.value
}
result.value.then(data => {
// 通过递归的方式处理相同的逻辑
next(data)
})
}
// 第一次调用 next 主要用于启动 Generator 函数
// 内部指针会从函数头部开始执行,直到遇到第一个 yield 表达式
// 因此第一次 next 传递的参数没有任何含义(这里传递只是为了防止 TS 报错)
next(0)
}

co(gen)



温馨提示:TJ Holowaychuk 设计了一个 Generator 自动执行器 Co,使用 Co 的前提是 yield  命令后必须是 Promise 对象或者 Thunk 函数。Co 还可以支持并发的异步处理,具体可查看官方的 API 文档



需要注意的是 Generator 函数的返回值是一个 Iterator 遍历器对象,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
yield firstPromise(1);
yield nextPromise(2);
}

// 注意使用 next 是继发执行,而这里是并发执行
Promise.all([...gen()]).then((res) => {
console.log('res: ', res);
});

for (const promise of gen()) {
promise.then((res) => {
console.log('res: ', res);
});
}

Generator 函数的错误处理相对复杂一些,极端情况下需要对执行和 Generator 函数进行双重错误捕获,具体如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// 需要注意这里的reject 没有被捕获
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

type Gen = Generator<Promise<number>>;

function* gen(): Gen {
try {
yield firstPromise(1);
yield nextPromise(2);
} catch (err) {
console.error('Generator 函数错误捕获: ', err);
}
}

try {
const g = gen();
g.next();
// 返回 Promise 后还需要通过 Promise.prototype.catch 进行错误捕获
g.next();
// Generator 函数错误捕获
g.throw('err');
// 执行器错误捕获
g.throw('err');
} catch (err) {
console.error('执行错误捕获: ', err);
}

在使用 g.throw 的时候还需要注意以下一些事项:



  • 如果 Generator 函数本身没有捕获错误,那么 Generator 函数内部抛出的错误可以在执行处进行错误捕获

  • 如果 Generator 函数内部和执行处都没有进行错误捕获,则终止进程并抛出错误信息

  • 如果没有执行过 g.next,则 g.throw 不会在 Gererator 函数中被捕获(因为执行指针没有启动 Generator 函数的执行),此时可以在执行处进行执行错误捕获


Async


Async 是 Generator 函数的语法糖,相对于 Generator 而言 Async 的特性如下:



  • 内置执行器:Generator 函数需要设计手动执行器或者通用执行器(例如 Co 执行器)进行执行,Async 语法则内置了自动执行器,设计代码时无须关心执行步骤

  • yield 命令无约束:在 Generator 中使用 Co 执行器时 yield 后必须是 Promise 对象或者 Thunk 函数,而 Async 语法中的 await 后可以是 Promise 对象或者原始数据类型对象、数字、字符串、布尔值等(此时会对其进行 Promise.resolve() 包装处理) 

  • 返回 Promise: async 函数的返回值是 Promise 对象(返回原始数据类型会被 Promise 进行封装), 因此还可以作为 await  的命令参数,相对于 Generator 返回 Iterator 遍历器更加简洁实用


举个简单的示例:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
// 1s 后打印 2
console.log('firstResult: ', firstResult);
// 等待 firstPromise 的状态发生变化后执行
const nextResult = await nextPromise(firstResult);
// 2s 后打印 6
console.log('nextResult: ', nextResult);
return nextResult;
}

co();

co().then((res) => {
console.log('res: ', res); // 6
});

通过上述示例可以看出,async 函数的特性如下:



  • 调用 async 函数后返回的是一个 Promise 对象,通过 then 回调可以拿到 async 函数内部 return 语句的返回值  

  • 调用 async 函数后返回的 Promise 对象必须等待内部所有 await 对应的 Promise 执行完(这使得 async 函数可能是阻塞式执行)后才会发生状态变化,除非中途遇到了 return 语句

  • await 命令后如果是 Promise 对象,则返回 Promise 对象处理后的结果,如果是原始数据类型,则直接返回原始数据类型


上述代码是阻塞式执行,nextPromise 需要等待 firstPromise 执行完成后才能继续执行,如果希望两者能够并发执行,则可以进行下述设计:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
return await Promise.all([firstPromise(1), nextPromise(1)]);
}

co().then((res) => {
console.log('res: ', res); // [2,3]
});

除了使用 Promise 自带的并发执行 API,也可以通过让所有的 Promise 提前并发执行来处理:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('firstPromise');
setTimeout(() => resolve(result * 2), 10000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
console.log('nextPromise');
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
// 执行 firstPromise
const first = firstPromise(1);
// 和 firstPromise 同时执行 nextPromise
const next = nextPromise(1);
// 等待 firstPromise 结果回来
const firstResult = await first;
console.log('firstResult: ', firstResult);
// 等待 nextPromise 结果回来
const nextResult = await next;
console.log('nextResult: ', nextResult);
return nextResult;
}

co().then((res) => {
console.log('res: ', res); // 3
});

Async 的错误处理相对于 Generator 会更加简单,具体示例如下所示:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
const firstResult = await firstPromise(1);
console.log('firstResult: ', firstResult);
const nextResult = await nextPromise(1);
console.log('nextResult: ', nextResult);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res);
})
.catch((err) => {
console.error('err: ', err); // err: 2
});

async 函数内部抛出的错误,会导致函数返回的 Promise 对象变为 rejected 状态,从而可以通过 catch 捕获, 上述代码只是一个粗粒度的容错处理,如果希望 firstPromise 错误后可以继续执行 nextPromise,则可以通过 try...catch...async 函数里进行局部错误捕获:


const firstPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
// Promise 决断错误
setTimeout(() => reject(result * 2), 1000);
});
};

const nextPromise = (result: number): Promise<number> => {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(result * 3), 1000);
});
};

async function co() {
try {
await firstPromise(1);
} catch (err) {
console.error('err: ', err); // err: 2
}

// nextPromise 继续执行
const nextResult = await nextPromise(1);
return nextResult;
}

co()
.then((res) => {
console.log('res: ', res); // res: 3
})
.catch((err) => {
console.error('err: ', err);
});


温馨提示:Callback 是 Node.js 中经常使用的编程方式,Node.js 中很多原生的 API 都是采用 Callback 的形式进行异步设计,早期的 Node.js 经常会有 Callback 和 Promise 混用的情况,并且在很长一段时间里都没有很好的支持 Async 语法。如果你对 Node.js 和它的替代品 Deno 感兴趣,可以观看 Ryan Dahl 在 TS Conf 2019 中的经典演讲 Deno is a New Way to JavaScript



31、 Object.defineProperty 有哪几个参数?各自都有什么作用?


32、 Object.defineProperty 和 ES6 的 Proxy 有什么区别?



阅读链接:基于 Vue 实现一个 MVVM - 数据劫持的实现。



33、 ES6 中 Symbol、Map、Decorator 的使用场景有哪些?或者你在哪些库的源码里见过这些 API 的使用?


34、 为什么要使用 TypeScript ? TypeScript 相对于 JavaScript 的优势是什么?


35、 TypeScript 中 const 和 readonly 的区别?枚举和常量枚举的区别?接口和类型别名的区别?


36、 TypeScript 中 any 类型的作用是什么?


37、 TypeScript 中 any、never、unknown 和 void 有什么区别?


38、 TypeScript 中 interface 可以给 Function / Array / Class(Indexable)做声明吗?


39、 TypeScript 中可以使用 String、Number、Boolean、Symbol、Object 等给类型做声明吗?


40、 TypeScript 中的 this 和 JavaScript 中的 this 有什么差异?


41、 TypeScript 中使用 Unions 时有哪些注意事项?


42、 TypeScript 如何设计 Class 的声明?


43、 TypeScript 中如何联合枚举类型的 Key?


44、 TypeScript 中 ?.、??、!.、_、** 等符号的含义?


45、 TypeScript 中预定义的有条件类型有哪些?


46、 简单介绍一下 TypeScript 模块的加载机制?


47、 简单聊聊你对 TypeScript 类型兼容性的理解?抗变、双变、协变和逆变的简单理解?


48、 TypeScript 中对象展开会有什么副作用吗?


49、 TypeScript 中 interface、type、enum 声明有作用域的功能吗?


50、 TypeScript 中同名的 interface 或者同名的 interface 和 class 可以合并吗?


51、 如何使 TypeScript 项目引入并识别编译为 JavaScript 的 npm 库包?


52、 TypeScript 的 tsconfig.json 中有哪些配置项信息?


53、 TypeScript 中如何设置模块导入的路径别名?



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

收起阅读 »

前端面试知识点(一)

基础知识 基础知识主要包含以下几个方面: 基础:计算机原理、编译原理、数据结构、算法、设计模式、编程范式等基本知识了解 语法:JavaScript、ECMAScript、CSS、TypeScript、HTML、Node.js 等语法的了解和使用 框架:Rea...
继续阅读 »

基础知识


基础知识主要包含以下几个方面:



  • 基础:计算机原理、编译原理、数据结构、算法、设计模式、编程范式等基本知识了解

  • 语法:JavaScript、ECMAScript、CSS、TypeScript、HTML、Node.js 等语法的了解和使用

  • 框架:React、Vue、Egg、Koa、Express、Webpack 等原理的了解和使用

  • 工程:编译工具、格式工具、Git、NPM、单元测试、Nginx、PM2、CI / CD 了解和使用

  • 网络:HTTP、TCP、UDP、WebSocket、Cookie、Session、跨域、缓存、协议的了解

  • 性能:编译性能、监控、白屏检测、SEO、Service Worker 等了解

  • 插件:Chrome 、Vue CLI 、Webpack 等插件设计思路的理解

  • 系统:Mac、Windows、Linux 系统配置的实践

  • 后端:Redis 缓存、数据库、Graphql、SSR、模板引擎等了解和使用


基础


1、列举你所了解的计算机存储设备类型?


现代计算机以存储器为中心,主要由 CPU、I / O 设备以及主存储器三大部分组成。各个部分之间通过总线进行连接通信,具体如下图所示:
image.png
上图是一种多总线结构的示意图,CPU、主存以及 I / O 设备之间的所有数据都是通过总线进行并行传输,使用局部总线是为了提高 CPU 的吞吐量(CPU 不需要直接跟 I / O 设备通信),而使用高速总线(更贴近 CPU)和 DMA 总线则是为了提升高速 I / O 设备(外设存储器、局域网以及多媒体等)的执行效率。


主存包括随机存储器 RAM 和只读存储器 ROM,其中 ROM 又可以分为 MROM(一次性)、PROM、EPROM、EEPROM 。ROM 中存储的程序(例如启动程序、固化程序)和数据(例如常量数据)在断电后不会丢失。RAM 主要分为静态 RAM(SRAM) 和动态 RAM(DRAM) 两种类型(DRAM 种类很多,包括 SDRAM、RDRAM、CDRAM 等),断电后数据会丢失,主要用于存储临时程序或者临时变量数据。 DRAM 一般访问速度相对较慢。由于现代 CPU 读取速度要求相对较高,因此在 CPU 内核中都会设计 L1、L2 以及 L3 级别的多级高速缓存,这些缓存基本是由 SRAM 构成,一般访问速度较快。


2、一般代码存储在计算机的哪个设备中?代码在 CPU 中是如何运行的?


高级程序设计语言不能直接被计算机理解并执行,需要通过翻译程序将其转换成特定处理器上可执行的指令,计算机 CPU 的简单工作原理如下所示:
image.png
CPU 主要由控制单元、运算单元和存储单元组成(注意忽略了中断系统),各自的作用如下:



  • 控制单元:在节拍脉冲的作用下,将程序计数器(Program Counter,PC)指向的主存或者多级高速缓存中的指令地址送到地址总线,接着获取指令地址所对应的指令并放入指令寄存器 (Instruction Register,IR)中,然后通过指令译码器(Instruction Decoder,ID)分析指令需要进行的操作,最后通过操作控制器(Operation Controller,OC)向其他设备发出微操作控制信号。

  • 运算单元:如果控制单元发出的控制信号存在算术运算(加、减、乘、除、增 1、减 1、取反等)或者逻辑运算(与、或、非、异或),那么需要通过运算单元获取存储单元的计算数据进行处理。

  • 存储单元:包括片内缓存和寄存器组,是 CPU 中临时数据的存储地方。CPU 直接访问主存数据大概需要花费数百个机器周期,而访问寄存器或者片内缓存只需要若干个或者几十个机器周期,因此会使用内部寄存器或缓存来存储和获取临时数据(即将被运算或者运算之后的数据),从而提高 CPU 的运行效率。


除此之外,计算机系统执行程序指令时需要花费时间,其中取出一条指令并执行这条指令的时间叫指令周期。指令周期可以分为若干个阶段(取指周期、间址周期、执行周期和中断周期),每个阶段主要完成一项基本操作,完成基本操作的时间叫机器周期。机器周期是时钟周期的分频,例如最经典的 8051 单片机的机器周期为 12 个时钟周期。时钟周期是 CPU 工作的基本时间单位,也可以称为节拍脉冲或 T 周期(CPU 主频的倒数) 。假设 CPU 的主频是 1 GHz(1 Hz 表示每秒运行 1 次),那么表示时钟周期为 1 / 109 s。理论上 CPU 的主频越高,程序指令执行的速度越快。


3、什么是指令和指令集?


上图右侧主存中的指令是 CPU 可以支持的处理命令,一般包含算术指令(加和减)、逻辑指令(与、或和非)、数据指令(移动、输入、删除、加载和存储)、流程控制指令以及程序结束指令等,由于 CPU 只能识别二进制码,因此指令是由二进制码组成。除此之外,指令的集合称为指令集(例如汇编语言就是指令集的一种表现形式),常见的指令集有精简指令集(ARM)和复杂指令集(Inter X86)。一般指令集决定了 CPU 处理器的硬件架构,规定了处理器的相应操作。


4、复杂指令集和精简指令集有什么区别?


5、JavaScript 是如何运行的?解释型语言和编译型语言的差异是什么?


早期的计算机只有机器语言时,程序设计必须用二进制数(0 和 1)来编写程序,并且要求程序员对计算机硬件和指令集非常了解,编程的难度较大,操作极易出错。为了解决机器语言的编程问题,慢慢开始出现了符号式的汇编语言(采用 ADD、SUB、MUL、DIV 等符号代表加减乘除)。为了使得计算机可以识别汇编语言,需要将汇编语言翻译成机器能够识别的机器语言(处理器的指令集):
image.png
由于每一种机器的指令系统不同,需要不同的汇编语言程序与之匹配,因此程序员往往需要针对不同的机器了解其硬件结构和指令系统。为了可以抹平不同机器的指令系统,使得程序员可以更加关注程序设计本身,先后出现了各种面向问题的高级程序设计语言,例如 BASIC 和 C,具体过程如下图所示:
image.png
高级程序语言会先翻译成汇编语言或者其他中间语言,然后再根据不同的机器翻译成机器语言进行执行。除此之外,汇编语言虚拟机和机器语言机器之间还存在一层操作系统虚拟机,主要用于控制和管理操作系统的全部硬件和软件资源(随着超大规模集成电路技术的不断发展,一些操作系统的软件功能逐步由硬件来替换,例如目前的操作系统已经实现了部分程序的固化,简称固件,将程序永久性的存储在 ROM 中)。机器语言机器还可以继续分解成微程序机器,将每一条机器指令翻译成一组微指令(微程序)进行执行。


上述虚拟机所提供的语言转换程序被称为编译器,主要作用是将某种语言编写的源程序转换成一个等价的机器语言程序,编译器的作用如下图所示:
image.png
例如 C 语言,可以先通过 gcc 编译器生成 Linux 和 Windows 下的目标 .o 和 .obj 文件(object 文件,即目标文件),然后将目标文件与底层系统库文件、应用程序库文件以及启动文件链接成可执行文件在目标机器上执行。



温馨提示:感兴趣的同学可以了解一下 ARM 芯片的程序运行原理,包括使用 IDE 进行程序的编译(IDE 内置编译器,主流编译器包含 ARMCC、IAR 以及 GCC FOR ARM 等,其中一些编译器仅仅随着 IDE 进行捆绑发布,不提供独立使用的能力,而一些编译器则随着 IDE 进行发布的同时,还提供命令行接口的独立使用方式)、通过串口进行程序下载(下载到芯片的代码区初始启动地址映射的存储空间地址)、启动的存储空间地址映射(包括系统存储器、闪存 FLASH、内置 SRAM 等)、芯片的程序启动模式引脚 BOOT 的设置(例如调试代码时常常选择内置 SRAM、真正程序运行的时候选择闪存 FLASH)等。



如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。


除此之外,有些程序设计语言将编译的过程和最终转换成目标程序进行执行的过程混合在一起,这种语言转换程序通常被称为解释器,主要作用是将某种语言编写的源程序作为输入,将该源程序执行的结果作为输出,解释器的作用如下图所示:


image.png


解释器和编译器有很多相似之处,都需要对源程序进行分析,并转换成目标机器可识别的机器语言进行执行。只是解释器是在转换源程序的同时立马执行对应的机器语言(转换和执行的过程不分离),而编译器得先把源程序全部转换成机器语言并产生目标文件,然后将目标文件写入相应的程序存储器进行执行(转换和执行的过程分离)。例如 Perl、Scheme、APL 使用解释器进行转换, C、C++ 则使用编译器进行转换,而 Java 和 JavaScript 的转换既包含了编译过程,也包含了解释过程。


6、简单描述一下 Babel 的编译过程?


7、JavaScript 中的数组和函数在内存中是如何存储的?


JavaScript 中的数组存储大致需要分为两种情况:



  • 同种类型数据的数组分配连续的内存空间

  • 存在非同种类型数据的数组使用哈希映射分配内存空间



温馨提示:可以想象一下连续的内存空间只需要根据索引(指针)直接计算存储位置即可。如果是哈希映射那么首先需要计算索引值,然后如果索引值有冲突的场景下还需要进行二次查找(需要知道哈希的存储方式)。



8、浏览器和 Node.js 中的事件循环机制有什么区别?



阅读链接:面试分享:两年工作经验成功面试阿里P6总结 - 了解 Event Loop 吗?



9、ES6 Modules 相对于 CommonJS 的优势是什么?


10、高级程序设计语言是如何编译成机器语言的?


11、编译器一般由哪几个阶段组成?数据类型检查一般在什么阶段进行?


12、编译过程中虚拟机的作用是什么?


13、什么是中间代码(IR),它的作用是什么?


14、什么是交叉编译?


编译器的设计是一个非常庞大和复杂的软件系统设计,在真正设计的时候需要解决两个相对重要的问题:



  • 如何分析不同高级程序语言设计的源程序

  • 如何将源程序的功能等价映射到不同指令系统的目标机器


为了解决上述两项问题,编译器的设计最终被分解成前端(注意这里所说的不是 Web 前端)和后端两个编译阶段,前端用于解决第一个问题,而后端用于解决第二个问题,具体如下图所示:
image.png
上图中的中间表示(Intermediate Representation,IR)是程序结构的一种表现方式,它会比 AST(后续讲解)更加接近汇编语言或者指令集,同时也会保留源程序中的一些高级信息,除此之外 ,它的种类很多,包括三地址码(Three Address Code, TAC)静态单赋值形式(Static Single Assignment Form, SSA)以及基于栈的 IR 等,具体作用包括:



  • 靠近前端部分主要适配不同的源程序,靠近后端部分主要适配不同的指令集,更易于编译器的错误调试,容易识别是 IR 之前还是之后出问题

  • 如下左图所示,如果没有 IR,那么源程序到指令集之间需要进行一一适配,而有了中间表示,则可以使得编译器的职责更加分离,源程序的编译更多关注如何转换成 IR,而不是去适配不同的指令集

  • IR 本身可以做到多趟迭代从而优化源程序,在每一趟迭代的过程中可以研究代码并记录优化的细节,方便后续的迭代查找并利用这些优化信息,最终可以高效输出更优的目标程序


image.png
由于 IR 可以进行多趟迭代进行程序优化,因此在编译器中可插入一个新的优化阶段,如下图所示:
image.png
优化器可以对 IR 处理一遍或者多遍,从而生成更快执行速度(例如找到循环中不变的计算并对其进行优化从而减少运算次数)或者更小体积的目标程序,也可能用于产生更少异常或者更低功耗的目标程序。除此之外,前端和后端内部还可以细分为多个处理步骤,具体如下图所示:
image.png
优化器中的每一遍优化处理都可以使用一个或多个优化技术来改进代码,每一趟处理最终都是读写 IR 的操作,这样不仅仅可以使得优化可以更加高效,同时也可以降低优化的复杂度,还提高了优化的灵活性,可以使得编译器配置不同的优化选项,达到组合优化的效果。


15、发布 / 订阅模式和观察者模式的区别是什么?



阅读链接:基于Vue实现一个简易MVVM - 观察者模式和发布/订阅模式



16、装饰器模式一般会在什么场合使用?


17、谈谈你对大型项目的代码解耦设计理解?什么是 Ioc?一般 DI 采用什么设计模式实现?


18、列举你所了解的编程范式?


编程范式(Programming paradigm)是指计算机编程的基本风格或者典型模式,可以简单理解为编程学科中实践出来的具有哲学和理论依据的一些经典原型。常见的编程范式有:



  • 面向过程(Process Oriented Programming,POP)

  • 面向对象(Object Oriented Programming,OOP)

  • 面向接口(Interface Oriented Programming, IOP)

  • 面向切面(Aspect Oriented Programming,AOP)

  • 函数式(Funtional Programming,FP)

  • 响应式(Reactive Programming,RP)

  • 函数响应式(Functional Reactive Programming,FRP)



阅读链接::如果你对于编程范式的定义相对模糊,可以继续阅读 What is the precise definition of programming paradigm? 了解更多。



不同的语言可以支持多种不同的编程范式,例如 C 语言支持 POP 范式,C++ 和 Java 语言支持 OOP 范式,Swift 语言则可以支持 FP 范式,而 Web 前端中的 JavaScript 可以支持上述列出的所有编程范式。


19、什么是面向切面(AOP)的编程?


20、什么是函数式编程?


顾名思义,函数式编程是使用函数来进行高效处理数据或数据流的一种编程方式。在数学中,函数的三要素是定义域、值域和**对应关系。假设 A、B 是非空数集,对于集合 A 中的任意一个数 x,在集合 B 中都有唯一确定的数 f(x) 和它对应,那么可以将 f 称为从 A 到 B 的一个函数,记作:y = f(x)。在函数式编程中函数的概念和数学函数的概念类似,主要是描述形参 x 和返回值 y 之间的对应关系,**如下图所示:




温馨提示:图片来自于简明 JavaScript 函数式编程——入门篇



在实际的编程中,可以将各种明确对应关系的函数进行传递、组合从而达到处理数据的最终目的。在此过程中,我们的关注点不在于如何去实现**对应关系,**而在于如何将各种已有的对应关系进行高效联动,从而可快速进行数据转换,达到最终的数据处理目的,提供开发效率。


简单示例


尽管你对函数式编程的概念有所了解,但是你仍然不知道函数式编程到底有什么特点。这里我们仍然拿 OOP 编程范式来举例,假设希望通过 OOP 编程来解决数学的加减乘除问题:


class MathObject {
constructor(private value: number) {}
public add(num: number): MathObject {
this.value += num;
return this;
}
public multiply(num: number): MathObject {
this.value *= num;
return this;
}
public getValue(): number {
return this.value;
}
}

const a = new MathObject(1);
a.add(1).multiply(2).add(a.multiply(2).getValue());

我们希望通过上述程序来解决 (1 + 2) * 2 + 1 * 2 的问题,但实际上计算出来的结果是 24,因为在代码内部有一个 this.value 的状态值需要跟踪,这会使得结果不符合预期。 接下来我们采用函数式编程的方式:


function add(a: number, b: number): number {
return a + b;
}

function multiply(a: number, b: number): number {
return a * b;
}

const a: number = 1;
const b: number = 2;

add(multiply(add(a, b), b), multiply(a, b));

以上程序计算的结果是 8,完全符合预期。我们知道了 addmultiply 两个函数的实际对应关系,通过将对应关系进行有效的组合和传递,达到了最终的计算结果。除此之外,这两个函数还可以根据数学定律得出更优雅的组合方式:


add(multiply(add(a, b), b), multiply(a, b));

// 根据数学定律分配律:a * b + a * c = a * (b + c),得出:
// (a + b) * b + a * b = (2a + b) * b

// 简化上述函数的组合方式
multiply(add(add(a, a), b), b);


我们完全不需要追踪类似于 OOP 编程范式中可能存在的内部状态数据,事实上对于数学定律中的结合律、交换律、同一律以及分配律,上述的函数式编程代码足可以胜任。


原则


通过上述简单的例子可以发现,要实现高可复用的函数**(对应关系)**,一定要遵循某些特定的原则,否则在使用的时候可能无法进行高效的传递和组合,例如



  • 高内聚低耦合

  • 最小意外原则

  • 单一职责原则

  • ...


如果你之前经常进行无原则性的代码设计,那么在设计过程中可能会出现各种出乎意料的问题(这是为什么新手老是出现一些稀奇古怪问题的主要原因)。函数式编程可以有效的通过一些原则性的约束使你设计出更加健壮和优雅的代码,并且在不断的实践过程中进行经验式叠加,从而提高开发效率。


特点


虽然我们在使用函数的过程中更多的不再关注函数如何实现(对应关系),但是真正在使用和设计函数的时候需要注意以下一些特点:



  • 声明式(Declarative Programming)

  • 一等公民(First Class Function)

  • 纯函数(Pure Function)

  • 无状态和数据不可变(Statelessness and Immutable Data)

  • ...


声明式


我们以前设计的代码通常是命令式编程方式,这种编程方式往往注重具体的实现的过程(对应关系),而函数式编程则采用声明式的编程方式,往往注重如何去组合已有的**对应关系。**简单举个例子:


// 命令式
const array = [0.8, 1.7, 2.5, 3.4];
const filterArray = [];

for (let i = 0; i < array.length; i++) {
const integer = Math.floor(array[i]);
if (integer < 2) {
continue;
}
filterArray.push(integer);
}

// 声明式
// map 和 filter 不会修改原有数组,而是产生新的数组返回
[0.8, 1.7, 2.5, 3.4].map((item) => Math.floor(item)).filter((item) => item > 1);

命令式代码一步一步的告诉计算机需要执行哪些语句,需要关心变量的实例化情况、循环的具体过程以及跟踪变量状态的变化过程。声明式代码更多的不再关心代码的具体执行过程,而是采用表达式的组合变换去处理问题,不再强调怎么做,而是指明**做什么。**声明式编程方式可以将我们设计代码的关注点彻底从过程式解放出来,从而提高开发效率。


一等公民


在 JavaScript 中,函数的使用非常灵活,例如可以对函数进行以下操作:


interface IHello {
(name: string): string;
key?: string;
arr?: number[];
fn?(name: string): string;
}

// 函数声明提升
console.log(hello instanceof Object); // true

// 函数声明提升
// hello 和其他引用类型的对象一样,都有属性和方法
hello.key = 'key';
hello.arr = [1, 2];
hello.fn = function (name: string) {
return `hello.fn, ${name}`;
};

// 函数声明提升
// 注意函数表达式不能在声明前执行,例如不能在这里使用 helloCopy('world')
hello('world');

// 函数
// 创建新的函数对象,将函数的引用指向变量 hello
// hello 仅仅是变量的名称
function hello(name: string): string {
return `hello, ${name}`;
}

console.log(hello.key); // key
console.log(hello.arr); // [1,2]
console.log(hello.name); // hello

// 函数表达式
const helloCopy: IHello = hello;
helloCopy('world');

function transferHello(name: string, hello: Hello) {
return hello('world');
}

// 把函数对象当作实参传递
transferHello('world', helloCopy);

// 把匿名函数当作实参传递
transferHello('world', function (name: string) {
return `hello, ${name}`;
});


通过以上示例可以看出,函数继承至对象并拥有对象的特性。在 JavaScript 中可以对函数进行参数传递、变量赋值或数组操作等等,因此把函数称为一等公民。函数式编程的核心就是对函数进行组合或传递,JavaScript 中函数这种灵活的特性是满足函数式编程的重要条件。


纯函数


纯函数是是指在相同的参数调用下,函数的返回值唯一不变。这跟数学中函数的映射关系类似,同样的 x 不可能映射多个不同的 y。使用函数式编程会使得函数的调用非常稳定,从而降低 Bug 产生的机率。当然要实现纯函数的这种特性,需要函数不能包含以下一些副作用:



  • 操作 Http 请求

  • 可变数据(包括在函数内部改变输入参数)

  • DOM 操作

  • 打印日志

  • 访问系统状态

  • 操作文件系统

  • 操作数据库

  • ...


从以上常见的一些副作用可以看出,纯函数的实现需要遵循最小意外原则,为了确保函数的稳定唯一的输入和输出,尽量应该避免与函数外部的环境进行任何交互行为,从而防止外部环境对函数内部产生无法预料的影响。纯函数的实现应该自给自足,举几个例子:


// 如果使用 const 声明 min 变量(基本数据类型),则可以保证以下函数的纯粹性
let min: number = 1;

// 非纯函数
// 依赖外部环境变量 min,一旦 min 发生变化则输入和返回不唯一
function isEqual(num: number): boolean {
return num === min;
}

// 纯函数
function isEqual(num: number): boolean {
return num === 1;
}

// 非纯函数
function request<T, S>(url: string, params: T): Promise<S> {
// 会产生请求成功和请求失败两种结果,返回的结果可能不唯一
return $.getJson(url, params);
}

// 纯函数
function request<T, S>(url: string, params: T) : () => Promise<S> {
return function() {
return $.getJson(url, params);
}
}

纯函数的特性使得函数式编程具备以下特性:



  • 可缓存性(Cacheable)

  • 可移植性(Portable)

  • 可测试性(Testable)


可缓存性和可测试性基于纯函数输入输出唯一不变的特性,可移植性则主要基于纯函数不依赖外部环境的特性。这里举一个可缓存的例子:


interface ICache<T> {
[arg: string]: T;
}

interface ISquare<T> {
(x: T): T;
}

// 简单的缓存函数(忽略通用性和健壮性)
function memoize<T>(fn: ISquare<T>): ISquare<T> {
const cache: ICache<T> = {};
return function (x: T) {
const arg: string = JSON.stringify(x);
cache[arg] = cache[arg] || fn.call(fn, x);
return cache[arg];
};
}

// 纯函数
function square(x: number): number {
return x * x;
}

const memoSquare = memoize<number>(square);
memoSquare(4);

// 不会再次调用纯函数 square,而是直接从缓存中获取值
// 由于输入和输出的唯一性,获取缓存结果可靠稳定
// 提升代码的运行效率
memoSquare(4);

无状态和数据不可变


在函数式编程的简单示例中已经可以清晰的感受到函数式编程绝对不能依赖内部状态,而在纯函数中则说明了函数式编程不能依赖外部的环境或状态,因为一旦依赖的状态变化,不能保证函数根据对应关系所计算的返回值因为状态的变化仍然保持不变。


这里单独讲解一下数据不可变,在 JavaScript 中有很多数组操作的方法,举个例子:


const arr = [1, 2, 3];

console.log(arr.slice(0, 2)); // [1, 2]
console.log(arr); // [1, 2, 3]
console.log(arr.slice(0, 2)); // [1, 2]
console.log(arr); // [1, 2, 3]

console.log(arr.splice(0, 1)); // [1]
console.log(arr); // [2, 3]
console.log(arr.splice(0, 1)); // [2]
console.log(arr); // [3]

这里的 slice 方法多次调用都不会改变原有数组,且会产生相同的输出。而 splice 每次调用都在修改原数组,且产生的输出也不相同。 在函数式编程中,这种会改变原有数据的函数已经不再是纯函数,应该尽量避免使用。



阅读链接:如果想要了解更深入的函数式编程知识点,可以额外阅读函数式编程指北



21、响应式编程的使用场景有哪些?


响应式编程是一种基于观察者(发布 / 订阅)模式并且面向异步(Asynchronous)数据流(Data Stream)和变化传播的声明式编程范式。响应式编程主要适用的场景包含:



  • 用户和系统发起的连续事件处理,例如鼠标的点击、键盘的按键或者通信设备发起的信号等

  • 非可靠的网络或者通信处理(例如 HTTP 网络的请求重试)

  • 连续的异步 IO 处理

  • 复杂的继发事务处理(例如一次事件涉及到多个继发的网络请求)

  • 高并发的消息处理(例如 IM 聊天)

  • ...

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

收起阅读 »

『ios』NSProxy解决NStimer循环引用的思考

1.nstimer为什么回循环引用2.NSObject如何解决NStimer循环引用3.NSProxy如何解决NStimer循环引用4.为什么要用NSProxy,优势在哪围绕上面几个问题我们来思考一下1.nstimer为什么回循环引用self.timer = ...
继续阅读 »

1.nstimer为什么回循环引用
2.NSObject如何解决NStimer循环引用
3.NSProxy如何解决NStimer循环引用
4.为什么要用NSProxy,优势在哪
围绕上面几个问题我们来思考一下

1.nstimer为什么回循环引用

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(timerTest) userInfo:nil repeats:YES];
self强引用timer,timer强引用self.
那是否可以改为

__weak typeof(self)weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:weakSelf selector:@selector(timerTest) userInfo:nil repeats:YES];
答案也是不行的。target其实里面做的是一个赋值操作。
我们可以从gnu里面的源码中发现这个问题。所以在外面设置weakSelf是不能解决这个问题,如果是block那是可以解决的。所以我们现在就要添加一个中间变量,来解开这个环。



2.NSObject如何解决NStimer循环引用
这里我们可以利用消息转发阶段的forwardingTargetForSelector函数来解决这个问题。

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:[XHObjectProxy proxyWithTarget:self] selector:@selector(timerTest) userInfo:nil repeats:YES];

@interface XHObjectProxy : NSObject
+ (instancetype)proxyWithTarget:(id)target;
@property (weak, nonatomic) id target;
@end

@implementation XHObjectProxy
+ (instancetype)proxyWithTarget:(id)target
{
XHObjectProxy *proxy = [[XHObjectProxy alloc] init];
proxy.target = target;
return proxy;
}
- (id)forwardingTargetForSelector:(SEL)aSelector
{
return self.target;
}
@end

设置XHObjectProxy这个中间件,然后传入target,并且用一个弱引用的target来接受他。从而实现解开循环。
当timer调用timerTest方法的时候,很显然XHObjectProxy没有这个方法,所以就进入了消息转发阶段。
当到达forwardingTargetForSelector 这个阶段的时候,直接返回self.target也就是[XHObjectProxy proxyWithTarget:self] 中的self。所以可以解决这个问题。

3.NSProxy如何解决NStimer循环引用
NSProxy跟NSObject一样,也是一个基类,是一个实现<NSObject>协议的基类。

@class NSMethodSignature, NSInvocation;

NS_ASSUME_NONNULL_BEGIN

NS_ROOT_CLASS
@interface NSProxy <NSObject> {
__ptrauth_objc_isa_pointer Class isa;
}

+ (id)alloc;
+ (id)allocWithZone:(nullable NSZone *)zone NS_AUTOMATED_REFCOUNT_UNAVAILABLE;
+ (Class)class;

- (void)forwardInvocation:(NSInvocation *)invocation;
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel NS_SWIFT_UNAVAILABLE("NSInvocation and related APIs not available");
- (void)dealloc;
- (void)finalize;
@property (readonly, copy) NSString *description;
@property (readonly, copy) NSString *debugDescription;
+ (BOOL)respondsToSelector:(SEL)aSelector;

- (BOOL)allowsWeakReference API_UNAVAILABLE(macos, ios, watchos, tvos);
- (BOOL)retainWeakReference API_UNAVAILABLE(macos, ios, watchos, tvos);

// - (id)forwardingTargetForSelector:(SEL)aSelector;

@end

里面有消息转发第三阶段的两个方法

- (void)forwardInvocation:(NSInvocation *)invocation;
- (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel

所以可以这么写

@interface XHRealProxy : NSProxy
+ (instancetype)proxyWithTarget:(id)target;
@property (weak, nonatomic) id target;
@end
@implementation XHRealProxy

+ (instancetype)proxyWithTarget:(id)target
{
// NSProxy对象不需要调用init,因为它本来就没有init方法
XHRealProxy *proxy = [XHRealProxy alloc];
proxy.target = target;
return proxy;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel
{
return [self.target methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
[invocation invokeWithTarget:self.target];
}
@end

经过测试我们发现在同时写forwardingTargetForSelector 和 methodSignatureForSelector 这两个方法的情况下,不会执行第二阶段的转发,直接进入methodSignatureForSelector。

4.为什么要用NSProxy,优势在哪
我们一般的方法调用都是通过objc_msgSend来进行的,然后经过消息转发,一步一步的进行。
这张图是NSObject的方法调用





  ViewController *vc = [[ViewController alloc] init];

XHRealProxy *proxy1 = [XHRealProxy proxyWithTarget:vc];

XHObjectProxy *proxy2 = [XHObjectProxy proxyWithTarget:vc];
通过代码进行测试,发现继承NSProxy的类,直接执行了methodSignatureForSelector方法,而继承NSObject的类,直接执行了forwardingTargetForSelector方法。
然后又进了nstimer的方法进行测试。发现不管是NSProxy 还是 NSObject 都会先直接走forwardingTargetForSelector这个方法。
觉得很奇怪,然后去gnu上去看源码。发现里面重写了isKindOfClass这个方法,然后里面直接调用了methodSignatureForSelector

/**
* Calls the -forwardInvocation: method to determine if the 'real' object
* referred to by the proxy is an instance of the specified class.
* Returns the result.<br />
* NB. The default operation of -forwardInvocation: is to raise an exception.
*/

- (BOOL) isKindOfClass: (Class)aClass
{
NSMethodSignature *sig;
NSInvocation *inv;
BOOL ret;

sig = [self methodSignatureForSelector: _cmd];
inv = [NSInvocation invocationWithMethodSignature: sig];
[inv setSelector: _cmd];
[inv setArgument: &aClass atIndex: 2];
[self forwardInvocation: inv];
[inv getReturnValue: &ret];
return ret;
}

/**
* Calls the -forwardInvocation: method to determine if the 'real' object
* referred to by the proxy is an instance of the specified class.
* Returns the result.<br />
* NB. The default operation of -forwardInvocation: is to raise an exception.
*/

- (BOOL) isMemberOfClass: (Class)aClass
{
NSMethodSignature *sig;
NSInvocation *inv;
BOOL ret;

sig = [self methodSignatureForSelector: _cmd];
inv = [NSInvocation invocationWithMethodSignature: sig];
[inv setSelector: _cmd];
[inv setArgument: &aClass atIndex: 2];
[self forwardInvocation: inv];
[inv getReturnValue: &ret];
return ret;
}

- (BOOL) conformsToProtocol: (Protocol*)aProtocol
{
NSMethodSignature *sig;
NSInvocation *inv;
BOOL ret;

sig = [self methodSignatureForSelector: _cmd];
inv = [NSInvocation invocationWithMethodSignature: sig];
[inv setSelector: _cmd];
[inv setArgument: &aProtocol atIndex: 2];
[self forwardInvocation: inv];
[inv getReturnValue: &ret];
return ret;
}

为什么NSProxy的效率更高?因为他是直接走消息转发第三步methodSignatureForSelector,而nsobject需要走objc_msgSend整个流程,所以效率更高。


作者:butterflyer
链接:https://www.jianshu.com/p/a079fd0f7d61
收起阅读 »

MQTT通信协议介绍

一:MQTT协议介绍MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是IBM开发的一个即时通讯协议,它是一种轻量级的、基于代理的“发布/订阅”模式的消息传输协议。其具有协议简洁、小巧、可扩展性强、省流量、等优...
继续阅读 »

一:MQTT协议介绍

MQTT(Message Queuing Telemetry Transport,消息队列遥测传输)是IBM开发的一个即时通讯协议,它是一种轻量级的、基于代理的“发布/订阅”模式的消息传输协议。其具有协议简洁小巧可扩展性强省流量、等优点。可在不可靠的网络环境中进行扩展,适用于设备硬件存储空间或网络带宽有限的场景。使用MQTT协议,消息发送者与接收者不受时间和空间的限制。物联网平台支持设备使用MQTT协议接入。

二:MQTT协议的主要特点

1、使用发布/订阅消息模式,提供一对多的消息发布,解除应用程序耦合

2、使用 TCP/IP 提供网络连接

3、有三种级别的消息发布服务质量QoS(Quality of Service)

  • “至多一次”(Qos = 0),消息发布完全依赖底层 TCP/IP 网络。会发生消息丢失或重复。这一级别可用于如下情况,环境传感器数据,丢失一次读记录无所谓,因为不久后还会有第二次发送。

  • “至少一次”(Qos = 1),确保消息到达,但消息重复可能会发生。

  • “只有一次”(Qos = 2),确保消息到达一次。消息丢失和重复都是不可接受的,使用这个服务质量等级会有额外的开销。

三:MQTT协议的核心角色

MQTT 协议主要有三大核心角色:发布者(Publisher)、Broker代理服务器(转发者) 、订阅者(Subscriber) 。其中消息的发布者和订阅者都是客户端(Client)角色,消息代理是服务器,消息发布者可以同时是订阅者。 当Client发布某个主题的消息时,Broker会将该消息分发给任何已订阅该主题的Client。

image.png

MQTT服务器

MQTT服务端通常是一台服务器。它是MQTT信息传输的枢纽,负责将MQTT客户端发送来的信息传递给MQTT客户端。MQTT服务端还负责管理MQTT客户端。确保客户端之间的通讯顺畅,保证MQTT消息得以正确接收和准确投递。

MQTT客户端

MQTT客户端可以向服务端发布信息,也可以从服务端收取信息。我们把客户端发送信息的行为成为“发布”信息。而客户端要想从服务端收取信息,则首先要向服务端“订阅”信息。“订阅”信息这一操作很像我们在微信中订阅的公众号,当公众号更新时,微信会向订阅了该公众号的用户发送信息,告诉他们有文章更新了。

MQTT主题

刚刚我们在讲解MQTT客户端订阅信息时,使用了用户在微信中订阅公众号的这个例子。在MQTT通讯中,客户端所订阅的肯定不是一个个公众号,而是一个个“主题”。MQTT服务端在管理MQTT信息通讯时,就是使用“主题”来控制的。

四:连接MQTT服务端

MQTT客户端要想通讯,必须经过MQTT服务端。因此MQTT客户端无论是发布消息还是订阅消息,首先都要连接MQTT服务端。下面我们看一下MQTT客户端连接服务端的详细过程。

1、首先MQTT客户端将会向服务端发送连接请求。该请求实际上是一个包含有连接请求信息的数据包。这个数据包的官方名称为CONNECT image.png 2、MQTT服务端收到客户端连接请求后,会向客户端发送连接确认。同样的,该确认也是一个数据包。这个数据包官方名称为CONNACK

image.png

以上就是MQTT客户端在连接服务端的两步操作。接下来,我们一起来了解一下客户端在连接服务端时所发送的CONNECT报文内容。

clientId – 客户端ID

ClientId是MQTT客户端的标识。MQTT服务端用该标识来识别客户端。因此ClientId必须是独立的。如果两个MQTT客户端使用相同ClientId标识,服务端会把它们当成同一个客户端来处理。通常ClientId是由一串字符所构成的

username(用户名)和password(密码)

这里的用户名和密码是用于客户端连接服务端时进行认证需要的。有些MQTT服务端需要客户端在连接时提供用户名和密码。只有客户端正确提供了用户名和密码后,才能连接服务端。否则服务端将会拒绝客户端连接,那么客户端也就无法发布和订阅消息了。

username(用户名)和password(密码)是可选的CONNECT信息。也就是说,有些服务端开启了客户端用户密码认证,这种服务端需要客户端在连接时正确提供认证信息才能连接。当然,那些没有开启用户密码认证的服务端无需客户端提供用户名和密码认证信息

cleanSession – 清除会话

要说明cleanSession的具体含义,首先要从MQTT网络环境讲起。MQTT客户端与服务端的连接可能不是非常稳定,在不稳定的网络环境下,要想保证所有信息传输都能够做到准确无误,这是非常困难的。

为了保证重要的MQTT报文可以被客户端准确无误的收到。在服务端向客户端发送报文后,客户端会向服务端返回一个确认报文。如果服务端没有收到客户端返回的确认报文,那么服务端就会认为刚刚发送给客户端的报文没有被准确无误的送达。在这种情况下,服务端将会执行以下两个操作:

1、将尚未被客户端确认的报文保存起来

2、再次尝试向客户端发送报文,并且再次等待客户端发来确认信息。

如果 cleanSession 被设置为“true”。那么服务端不需要客户端确认收到报文,也不会保存任何报文。在这种情况下,即使客户端错过了服务端发来的报文,也没办法让服务端再次发送报文。 反过来,如果我们将 cleanSession 设置为”false”。那么服务端就知道,后续通讯中,客户端可能会要求我保存没有收到的报文。

keepAlive – 心跳时间间隔

MQTT服务端运行过程中,当有客户端因为某种原因断开了与服务端的连接,服务端需要实时了解这一情况。KeepAlive正是用于服务端了解客户端连接情况的。

客户端在没有向服务端发送信息时,可以定时向服务端发送一条消息。这条用于心跳机制的消息也被称作心跳请求。

心跳请求的作用正是用于告知服务端,当前客户端依然在线。服务端在收到客户端的心跳请求后,会回复一条消息。这条回复消息被称作心跳响应。 image.png

客户端在心跳间隔时间内,如果有消息发布,那就直接发布消息而不发布心跳请求,但是在心跳间隔时间内,客户端没有消息发布,那么它就会发布一条心跳请求给服务端,这个心跳请求的目的就是为了告诉服务端,我还在线。

五: SUBSCRIBE – 订阅主题

当客户端连接到服务端后,除了可以发布消息,也可以接收消息。所有MQTT消息都有主题。客户端要想接收消息,首先要订阅该消息的主题。这样,当有客户端向该主题发布消息后,订阅了该主题的客户端就能接收到消息了。

客户端要想订阅主题,首先要向服务端发送主题订阅请求。客户端是通过向服务端发送SUBSCRIBE报文来实现这一请求的。客户端在订阅主题时也可以明确QoS。服务端会根据SUBSCRIBE中的QoS来提供相应的服务保证。

收起阅读 »

WorkManager :工作链

工作链工作链也是WorkManager的一个非常重要的功能。你可以使用WorkManager创建工作链并将其加入队列。工作链用于指定多个依存任务并定义这些任务的运行顺序。当需要以特定顺序运行多个任务时,此功能尤其有用。例如,假设您的应用有三个 OneTimeW...
继续阅读 »

工作链

工作链也是WorkManager的一个非常重要的功能。

你可以使用WorkManager创建工作链并将其加入队列。工作链用于指定多个依存任务并定义这些任务的运行顺序。当需要以特定顺序运行多个任务时,此功能尤其有用。

例如,假设您的应用有三个 OneTimeWorkRequest对象:workAworkB 和 workC。这些任务必须按该顺序运行。如需对这些任务进行排队,请使用 WorkManager.beginWith(OneTimeWorkRequest)方法创建一个序列,并传递第一个 OneTimeWorkRequest对象;该方法会返回一个 WorkContinuation对象,以定义一个任务序列。然后,使用 WorkContinuation.then(OneTimeWorkRequest)依次添加剩余的 OneTimeWorkRequest 对象;最后,使用 WorkContinuation.enqueue()对整个序列进行排队:

WorkManager.getInstance(myContext)
  .beginWith(workA)
       // Note: WorkManager.beginWith() returns a
       // WorkContinuation object; the following calls are
       // to WorkContinuation methods
  .then(workB)    // FYI, then() returns a new WorkContinuation instance
  .then(workC)
  .enqueue();

A-B-C.png WorkManager会根据每个任务的指定约束,按请求的顺序运行任务。如果有任务返回Result.failure(),整个序列结束。

您还可以将多个 OneTimeWorkRequest对象传递给任何 beginWith(List)和 then(List)调用。如果您向单个方法调用传递多个 OneTimeWorkRequest对象,WorkManager会并行运行所有这些任务,然后再运行序列中的其他任务。例如:

WorkManager.getInstance(myContext)
   // First, run all the A tasks (in parallel):
  .beginWith(Arrays.asList(workA1, workA2, workA3))
   // ...when all A tasks are finished, run the single B task:
  .then(workB)
   // ...then run the C tasks (in parallel):
  .then(Arrays.asList(workC1, workC2))
  .enqueue();

A1A2A3-B-C1C2.png

您可以使用 WorkContinuation.combine(List)方法联接多个任务链来创建更为复杂的序列。例如,假设您要运行像这样的序列:

workmanager-chain.svg

如需设置该序列,请创建两个单独的链,然后将它们联接成第三个链:

WorkContinuation chain1 = WorkManager.getInstance(myContext)
  .beginWith(workA)
  .then(workB);
WorkContinuation chain2 = WorkManager.getInstance(myContext)
  .beginWith(workC)
  .then(workD);
WorkContinuation chain3 = WorkContinuation
  .combine(Arrays.asList(chain1, chain2))
  .then(workE);
chain3.enqueue();

在这种情况下,WorkManager会在 workB 之前运行 workA。它还会在 workD 之前运行 workC。在 workB 和 workD 都完成后,WorkManager会运行 workE

注意:虽然 WorkManager会按顺序运行各个子链,但并不保证 chain1 中的任务如何与 chain2 中的任务重叠。例如,workB 可能会在 workC 的前面或后面运行,或者两者也可能会同时运行。唯一可以保证的就是每个子链中的任务将按顺序运行,即 workB 会在 workA 完成之后再启动。

上面我们介绍了工作链的基本知识和基本的方法。下面我们来看一个示例。在本例中,有 3 个不同的工作器作业配置为运行(可能并行运行)。然后这些工作器的结果将联接起来,并传递给正在缓存的工作器作业。最后,该作业的输出将传递到上传工作器,由上传工作器将结果上传到远程服务器。


WorkManager.getInstance(myContext)
  // Candidates to run in parallel
  .beginWith(Arrays.asList(plantName1, plantName2, plantName3))
  // Dependent work (only runs after all previous work in chain)
  .then(cache)
  .then(upload)
  // Call enqueue to kick things off
  .enqueue();

输入合并器

当您链接 OneTimeWorkRequest 实例时,父级工作请求的输出将作为子级的输入传入。因此,在上面的示例中,plantName1plantName2 和 plantName3 的输出将作为 cache 请求的输入传入。

为了管理来自多个父级工作请求的输入,WorkManager 使用 InputMerger。

WorkManager 提供两种不同类型的 InputMerger

  • OverwritingInputMerger会尝试将所有输入中的所有键添加到输出中。如果发生冲突,它会覆盖先前设置的键。
  • ArrayCreatingInputMerger会尝试合并输入,并在必要时创建数组。

OverwritingInputMerger

OverwritingInputMerger 是默认的合并方法。如果合并过程中存在键冲突,键的最新值将覆盖生成的输出数据中的所有先前版本。

例如,如果每种植物的输入都有一个与其各自变量名称("plantName1""plantName2" 和 "plantName3")匹配的键,传递给 cache 工作器的数据将具有三个键值对。

chaining-overwriting-merger-example.png 如果存在冲突,那么最后一个工作器将在争用中“取胜”,其值将传递给 cache

chaining-overwriting-merger-conflict.png 由于工作请求是并行运行的,因此无法保证其运行顺序。在上面的示例中,plantName1 可以保留值 "tulip" 或 "elm",具体取决于最后写入的是哪个值。如果有可能存在键冲突,并且您需要在合并器中保留所有输出数据,那么 ArrayCreatingInputMerger 可能是更好的选择。

ArrayCreatingInputMerger

对于上面的示例,假设我们要保留所有植物名称工作器的输出,则应使用 ArrayCreatingInputMerger

OneTimeWorkRequest cache = new OneTimeWorkRequest.Builder(PlantWorker.class)
      .setInputMerger(ArrayCreatingInputMerger.class)
      .setConstraints(constraints)
      .build();

ArrayCreatingInputMerger 将每个键与数组配对。如果每个键都是唯一的,您会得到一系列一元数组。

chaining-array-merger-example.png 如果存在任何键冲突,那么所有对应的值会分组到一个数组中。

chaining-array-merger-conflict.png

链接和工作状态

只要工作成功完成(即,返回 Result.success()),OneTimeWorkRequest 链便会按顺序执行。运行时,工作请求可能会失败或被取消,这会对依存工作请求产生下游影响。

当第一个 OneTimeWorkRequest 被加入工作请求链队列时,所有后续工作请求会被屏蔽,直到第一个工作请求的工作完成为止。

chaining-enqueued-all-blocked.png

在加入队列且满足所有工作约束后,第一个工作请求开始运行。如果工作在根 OneTimeWorkRequest 或 List<OneTimeWorkRequest> 中成功完成(即返回 Result.success()),系统会将下一组依存工作请求加入队列。

chaining-enqueued-in-progress.png

只要每个工作请求都成功完成,工作请求链中的剩余工作请求就会遵循相同的运行模式,直到链中的所有工作都完成为止。这是最简单的用例,通常也是首选用例,但处理错误状态同样重要。

如果在工作器处理工作请求时出现错误,您可以根据您定义的退避政策来重试该请求。重试请求链中的某个请求意味着,系统将使用提供给该请求的输入数据仅对该请求进行重试。并行运行的所有其他作业均不会受到影响。

chaining-enqueued-retry.png

如果该重试政策未定义或已用尽,或者您以其他方式已达到 OneTimeWorkRequest 返回 Result.failure() 的某种状态,该工作请求和所有依存工作请求都会被标记为 FAILED.

chaining-enqueued-failed.png

OneTimeWorkRequest 被取消时遵循相同的逻辑。任何依存工作请求也会被标记为 CANCELLED,并且无法执行其工作。

chaining-enqueued-cancelled.png

请注意,如果要向已失败或已取消工作请求的链附加更多工作请求,新附加的工作请求也会分别标记为 FAILED 或 CANCELLED。如果您想扩展现有链的工作,需要ExistingWorkPolicy中的 APPEND_OR_REPLACE

下面我们验证下工作状态: (一)

        Data data1 = new Data.Builder().putString("key", "1").build();
       OneTimeWorkRequest uploadWorkRequest1 =
               new OneTimeWorkRequest.Builder(UploadWorker.class)
                      .setInputData(data1)
                      .addTag("work")
                       // Additional configuration
                      .build();

       Data data2 = new Data.Builder().putString("key", "2").build();
       OneTimeWorkRequest uploadWorkRequest2 =
               new OneTimeWorkRequest.Builder(UploadWorker.class)
                      .setInputData(data2)
                      .addTag("work")
                       // Additional configuration
                      .build();

       Data data3 = new Data.Builder().putString("key", "3").build();
       OneTimeWorkRequest uploadWorkRequest3 =
               new OneTimeWorkRequest.Builder(UploadWorker.class)
                      .setInputData(data3)
                      .addTag("work_work")
                       // Additional configuration
                      .build();
       Log.d(TAG, "WorkRequest 3 id is " + uploadWorkRequest3.getId());

       Data data4 = new Data.Builder().putString("key", "4").build();
       OneTimeWorkRequest uploadWorkRequest4 =
               new OneTimeWorkRequest.Builder(UploadWorker.class)
                      .setInputData(data4)
                      .addTag("work_work")
                       // Additional configuration
                      .build();
       Log.d(TAG, "WorkRequest 4 id is " + uploadWorkRequest4.getId());

       WorkManager workManager = WorkManager.getInstance(MainActivity.this);

       workManager.beginWith(Arrays.asList(uploadWorkRequest1,uploadWorkRequest2))
              .then(uploadWorkRequest3)
              .then(uploadWorkRequest4)
              .enqueue();

代码如上,打印出来的Log如下:

2021-01-12 23:17:05.106 30630-30665/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 1
2021-01-12 23:17:05.110 30630-30666/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 2
2021-01-12 23:17:05.194 30630-30669/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 3
2021-01-12 23:17:05.222 30630-30670/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 4

通过上面的Log发现如下:

①:的确先执行的1和2,然后执行的3再是4。

(二)

如果仅仅把OneTimeWorkRequest1添加一个延时,把代码变成如下:

        OneTimeWorkRequest uploadWorkRequest1 =
               new OneTimeWorkRequest.Builder(UploadWorker.class)
                      .setInitialDelay(10, TimeUnit.SECONDS)
                      .setInputData(data1)
                      .addTag("work")
                       // Additional configuration
                      .build();
.................

执行的Log如下:

2021-01-12 23:24:02.731 31097-31130/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 2
2021-01-12 23:24:12.652 31097-31149/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 1
2021-01-12 23:24:12.730 31097-31150/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 3
2021-01-12 23:24:12.763 31097-31151/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 4

通过上面的Log:

①:发现2已经先执行了,这说明1、2的执行没有先后顺序。

②:3和4等1执行完毕之后才执行。

(三)

再次修正上面的代码,在把Worker提交给系统的下一行,添加对于OneTimeWorkRequest1的cancel

................
       workManager.beginWith(Arrays.asList(uploadWorkRequest1,uploadWorkRequest2))
              .then(uploadWorkRequest3)
              .then(uploadWorkRequest4)
              .enqueue();

       workManager.cancelWorkById(uploadWorkRequest1.getId());

执行的Log如下:

2021-01-12 23:29:10.977 31585-31618/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 2

通过Log发现:

①:当把OneTimeWorkRequest1给cancel了,在工作链后面执行的OneTimeWorkRequest3和OneTimeWorkRequest4也被cancel掉了。

(四)

接下来我想验证下ExistingWorkPolicy.APPEND_OR_REPLACE.

修改代码如下:

..............
       WorkContinuation chain1 = workManager.beginUniqueWork("work&work", ExistingWorkPolicy.APPEND_OR_REPLACE,
Arrays.asList(uploadWorkRequest1,uploadWorkRequest2));

       WorkContinuation chain2 = workManager.beginUniqueWork("work&work", ExistingWorkPolicy.APPEND_OR_REPLACE,
Arrays.asList(uploadWorkRequest3,uploadWorkRequest4))
          .then(uploadWorkRequest5);

       chain1.enqueue();
       chain2.enqueue();

        workManager.cancelWorkById(uploadWorkRequest1.getId());

执行的Log如下:

2021-01-13 03:52:08.084 10457-10457/com.example.myapplication D/MainActivity: WorkRequest 3 is cancelled
2021-01-13 03:52:08.084 10457-10457/com.example.myapplication D/MainActivity: WorkRequest 2 is enqueued
2021-01-13 03:52:08.084 10457-10457/com.example.myapplication D/MainActivity: WorkRequest 4 is cancelled
2021-01-13 03:52:08.085 10457-10457/com.example.myapplication D/MainActivity: WorkRequest 1 is cancelled
2021-01-13 03:52:08.094 10457-10491/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 2
2021-01-13 03:52:08.123 10457-10457/com.example.myapplication D/MainActivity: WorkRequest 2 is succeeded

通过上面的Log发现:

①:由于先执行了WorkContinuaton1, 然后又把WorkContinuaton1中的WorkRequest1给Cancel了, 所以后续的WorkRequest3、4都受影响, 被cancel了。

②: 而WorkRequest2 由于APPEND_OR_REPLACE的影响, 能够正常执行。

(五)

如果把chain1.enqueue()和chain2.enqueue()的执行顺序调换下. 再次执行.

Log如下:

2021-01-13 04:04:33.951 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 1 is cancelled
2021-01-13 04:04:33.951 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 3 is enqueued
2021-01-13 04:04:33.951 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 4 is enqueued
2021-01-13 04:04:33.962 10938-11023/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 3
2021-01-13 04:04:33.968 10938-11024/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 4
2021-01-13 04:04:34.011 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 3 is succeeded
2021-01-13 04:04:34.011 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 5 is enqueued
2021-01-13 04:04:34.011 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 4 is succeeded
2021-01-13 04:04:34.020 10938-11026/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 5
2021-01-13 04:04:34.046 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 5 is running
2021-01-13 04:04:34.057 10938-11027/com.example.myapplication D/MainActivity: UploadWorker doWork and key is 2
2021-01-13 04:04:34.057 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 5 is succeeded
2021-01-13 04:04:34.057 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 2 is enqueued
2021-01-13 04:04:34.074 10938-10938/com.example.myapplication D/MainActivity: WorkRequest 2 is succeeded

通过上面的Log发现:

①: 在WorkContinuation2先执行的情况下, WorkContinuation2这边的工作链不受到WorkRequest1的cancel的影响。

②:WorkContinuation1的WorkRequest2因为APPEND_OR_REPLACE, 不受到WorkRequest1的cancel影响。

※上面的示例(四)和(五)在实际使用过程中需要特别注意, 在使用多个工作链的时候, 需要注意前一个执行的工作链的状态对后执行的工作链的影响。

收起阅读 »

一文带你理解Kotlin协程本质核心

1. 协程是什么协程是编译器的能力,因为协程并不需要操作系统和硬件的支持(线程需要),是编译器为了让开发者写代码更简单方便, 提供了一些关键字, 并在内部自动生成了处理字节码线程和协程的目的差异线程的目的是提高CPU资源使用率, 使多个任务得以并行的运行,是为...
继续阅读 »

1. 协程是什么

  • 协程是编译器的能力,因为协程并不需要操作系统和硬件的支持(线程需要),是编译器为了让开发者写代码更简单方便, 提供了一些关键字, 并在内部自动生成了处理字节码

线程和协程的目的差异

  • 线程的目的是提高CPU资源使用率, 使多个任务得以并行的运行,是为了服务于机器的.
  • 协程的目的是为了让多个任务之间更好的协作,主要体现在代码逻辑上,是为了服务开发者 (能提升资源的利用率, 但并不是原始目的)

线程和协程的调度差异

  • 线程的调度是系统完成的,一般是抢占式的,根据优先级来分配
  • 协程的调度是开发者根据程序逻辑指定好的,在不同的时期把资源合理的分配给不同的任务.

协程与线程的关系

  • 协程并不是取代线程,而且抽象于线程之上,线程是被分割的CPU资源,协程是组织好的代码流程,协程需要线程来承载运行,线程是协程的资源

2. 基本使用

2.1. CoroutineScope.launch

  • launch函数可以启动新协程而不将结果返回给调用方

2.1.1. 代码实现

//获取一个协程作用域用于创建协程
private val mScope = MainScope()

mScope.launch(Dispatchers.IO) {
//IO线程执行getStringInfo()方法,返回结果
var res = getStringInfo()
//获取结果后主线程提示更新
withContext(Dispatchers.Main) {
Alerter.create(this@LearnCoroutineActivity).setTitle("Result").setText(res).show()
}
}

private suspend fun getStringInfo(): String {
return withContext(Dispatchers.IO) {
//在这1000毫秒内该协程所处的线程不会阻塞
delay(1000)
"Coroutine-launch"
}
}

//在onDestroy生命周期方法之中要手动取消
override fun onDestroy() {
super.onDestroy()
mScope.cancel()
}

2.1.2. 步骤

  1. 获取一个协程作用域用于创建协程
  2. 通过协程作用域.launch方法启动新的协程任务
    1. 启动时可以指定执行线程
    2. 内部通过withContext()方法实现切换线程
  3. 在onDestroy生命周期方法之中要手动取消

2.2. CoroutineScope.async

  • async函数实现返回值处理或者并发处理

2.2.1. 返回值处理

private fun asyncReturn() {
mScope.launch(Dispatchers.Main) {
//新开一个协程去执行协程体,父协程的代码会接着往下走
var deferred = async(Dispatchers.IO) {
delay(1000)
"Coroutine-Async"
}
//等待async执行完成获取返回值,并不会阻塞线程,而是挂起,将线程的执行权交出去
//直到async的协程体执行完毕后,会恢复协程继续执行
val data = deferred.await()
Alerter.create(this@LearnCoroutineActivity).setTitle("Result").setText(data).show()
}
}

2.2.2. 并发处理

private fun asyncConcurrent() {
//coroutineContext的创建下文会有分析
var coroutineContext = Job() +
Dispatchers.Main +
CoroutineExceptionHandler { coroutineContext, throwable ->
Log.e(
"CoroutineException",
"CoroutineExceptionHandler: $throwable"
)
} +
CoroutineName("asyncConcurrent")
mScope.launch(coroutineContext) {
val job1 = async(Dispatchers.IO) {
delay(1000)
"job1-finish"
}
val job2 = async(Dispatchers.IO) {
delay(2000)
"job2-finish"
}
val job3 = async(Dispatchers.IO) {
delay(500)
"job3-finish"
}
//等待各job执行完 将结果合并
Alerter.create(this@LearnCoroutineActivity).setTitle("Result")
.setText("job1:${job1.await()},job2:${job2.await()},job3:${job3.await()}").show()
}
}

2.3. 协程作用域

  • MainScope是协程默认提供的作用域,但是还有其他作用域更为方便
  • 可使用lifecycleScope或者viewModelScope,这两种作用域会自动取消
  • 在UI组件中使用LifecycleOwner.lifecycleScope,在ViewModel中使用ViewModel.viewModelScope

3. CoroutineContext

  • CoroutineContext是一个特殊的集合,同时包含了Map和Set的特点

  • 集合内部的元素Element是根据key去对应(Map特点),但是不允许重复(Set特点)

  • Element之间可以通过+号进行组合

  • Element有如下四类,共同组成了CoroutineContext

    • Job:协程的唯一标识,用来控制协程的生命周期(new、active、completing、completed、cancelling、cancelled)
    • CoroutineDispatcher:指定协程运行的线程(IO、Default、Main、Unconfined)
    • CoroutineName: 指定协程的名称,默认为coroutine
    • CoroutineExceptionHandler: 指定协程的异常处理器,用来处理未捕获的异常

3.1. CoroutineDispatcher Element

  • 用于指定协程的运行线程
  • kotlin已经内置了CoroutineDispatcher的4个实现,可以通过Dispatchers的Default、IO、Main、Unconfined字段分别返回使用
public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = createDefaultDispatcher()
@JvmStatic
public val IO: CoroutineDispatcher = DefaultScheduler.IO
@JvmStatic
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
}

3.1.1. Default,IO

Default,IO其实内部用的是一个线程池,下面逐个解析,看实现原理

3.1.1.1. default
  • Default会根据useCoroutinesScheduler属性(默认为true)去获取对应的线程池
    • DefaultScheduler(useCoroutinesScheduler=ture):kotlin自己实现的线程池逻辑
    • CommonPool(useCoroutinesScheduler=false):java类库中的Executor实现线程池逻辑
internal actual fun createDefaultDispatcher(): CoroutineDispatcher =
if (useCoroutinesScheduler) DefaultScheduler else CommonPool
internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
.....
}
//委托类
public open class ExperimentalCoroutineDispatcher(
private val corePoolSize: Int,
private val maxPoolSize: Int,
private val idleWorkerKeepAliveNs: Long,
private val schedulerName: String = "CoroutineScheduler"
) : ExecutorCoroutineDispatcher() {
}
//java类库中的Executor实现线程池逻辑
internal object CommonPool : ExecutorCoroutineDispatcher() {}
//共同父类,定义行为
public abstract class ExecutorCoroutineDispatcher: CoroutineDispatcher(), Closeable {}
ExperimentalCoroutineDispatcher
  • DefaultScheduler的主要实现都在它的父类ExperimentalCoroutineDispatcher中
public open class ExperimentalCoroutineDispatcher(
private val corePoolSize: Int,
private val maxPoolSize: Int,
private val idleWorkerKeepAliveNs: Long,
private val schedulerName: String = "CoroutineScheduler"
) : ExecutorCoroutineDispatcher() {
public constructor(
corePoolSize: Int = CORE_POOL_SIZE,
maxPoolSize: Int = MAX_POOL_SIZE,
schedulerName: String = DEFAULT_SCHEDULER_NAME
) : this(corePoolSize, maxPoolSize, IDLE_WORKER_KEEP_ALIVE_NS, schedulerName)
....//省略一些供测试的方法,更好的跟踪同步状态
}
3.1.1.2. IO
  • IO的实现其实是LimitingDispatcher
val IO: CoroutineDispatcher = LimitingDispatcher(
this,
systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)),
"Dispatchers.IO",
TASK_PROBABLY_BLOCKING
)
LimitingDispatcher
  • IO的实现类会有一些最大请求限制,以及队列处理
private class LimitingDispatcher(
private val dispatcher: ExperimentalCoroutineDispatcher,
private val parallelism: Int,
private val name: String?,
override val taskMode: Int
) : ExecutorCoroutineDispatcher(), TaskContext, Executor {
//同步阻塞队列
private val queue = ConcurrentLinkedQueue<Runnable>()
//cas计数
private val inFlightTasks = atomic(0)

override fun dispatch(context: CoroutineContext, block: Runnable) = dispatch(block, false)

private fun dispatch(block: Runnable, tailDispatch: Boolean) {
var taskToSchedule = block
while (true) {

if (inFlight <= parallelism) {
//LimitingDispatcher的dispatch方法委托给了DefaultScheduler的dispatchWithContext方法
dispatcher.dispatchWithContext(taskToSchedule, this, tailDispatch)
return
}
..//省略了一些队列处理逻辑
}
}
}

3.1.2. CoroutineScheduler

  • Default、IO其实都是共享CoroutineScheduler线程池,Kotlin实现了一套线程池两种调度策略
  • 通过内部的mode区分
fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
......
if (task.mode == TASK_NON_BLOCKING) {
if (skipUnpark) return
signalCpuWork()
} else {
signalBlockingWork(skipUnpark = skipUnpark)
}
}
Mode
TypeMode
DefaultTASK_NON_BLOCKING
IOTASK_PROBABLY_BLOCKING
处理策略
TypeMode
DefaultCoroutineScheduler最多有corePoolSize个线程被创建,corePoolSize它的取值为max(2, CPU核心数)
即它会尽量的等于CPU核心数
IO创建比corePoolSize更多的线程来运行IO型任务,但不能大于maxPoolSize
1.公式:max(corePoolSize, min(CPU核心数 * 128, 2^21 - 2)),即大于corePoolSize,小于2^21 - 2
2.2^21 - 2是一个很大的数约为2M,但是CoroutineScheduler是不可能创建这么多线程的,所以就需要外部限制提交的任务数
3.Dispatchers.IO构造时就通过LimitingDispatcher默认限制了最大线程并发数parallelism为max(64, CPU核心数),即最多只能提交parallelism个任务到CoroutineScheduler中执行,剩余的任务被放进队列中等待。
适合场景
TypeMode
Default1.CPU密集型任务的特点是执行任务时CPU会处于忙碌状态,任务会消耗大量的CPU资源
2.复杂计算、视频解码等,如果此时线程数太多,超过了CPU核心数,那么这些超出来的线程是得不到CPU的执行的,只会浪费内存资源
3.因为线程本身也有栈等空间,同时线程过多,频繁的线程切换带来的消耗也会影响线程池的性能
4.对于CPU密集型任务,线程池并发线程数等于CPU核心数才能让CPU的执行效率最大化
IO1.IO密集型任务的特点是执行任务时CPU会处于闲置状态,任务不会消耗大量的CPU资源
2.网络请求、IO操作等,线程执行IO密集型任务时大多数处于阻塞状态,处于阻塞状态的线程是不占用CPU的执行时间
3.此时CPU就处于闲置状态,为了让CPU忙起来,执行IO密集型任务时理应让线程的创建数量更多一点,理想情况下线程数应该等于提交的任务数,对于这些多创建出来的线程,当它们闲置时,线程池一般会有一个超时回收策略,所以大部分情况下并不会占用大量的内存资源
4.但也会有极端情况,所以对于IO密集型任务,线程池并发线程数应尽可能地多才能提高CPU的吞吐量,这个尽可能地多的程度并不是无限大,而是根据业务情况设定,但肯定要大于CPU核心数。

3.1.3. Unconfined

  • 任务执行在默认的启动线程。之后由调用resume的线程决定恢复协程的线程。
internal object Unconfined : CoroutineDispatcher() {
//为false为不需要dispatch
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false

override fun dispatch(context: CoroutineContext, block: Runnable) {
// 只有当调用yield方法时,Unconfined的dispatch方法才会被调用
// yield() 表示当前协程让出自己所在的线程给其他协程运行
val yieldContext = context[YieldContext]
if (yieldContext != null) {
yieldContext.dispatcherWasUnconfined = true
return
}
throw UnsupportedOperationException("Dispatchers.Unconfined.dispatch function can only be used by the yield function. " +
"If you wrap Unconfined dispatcher in your code, make sure you properly delegate " +
"isDispatchNeeded and dispatch calls.")
}
}
  • 每一个协程都有对应的Continuation实例,其中的resumeWith用于协程的恢复,存在于DispatchedContinuation
DispatchedContinuation
  • 我们重点看resumeWith的实现以及类委托
internal class DispatchedContinuation<in T>(
@JvmField val dispatcher: CoroutineDispatcher,
@JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED), CoroutineStackFrame, Continuation<T> by continuation {
.....
override fun resumeWith(result: Result<T>) {
val context = continuation.context
val state = result.toState()
if (dispatcher.isDispatchNeeded(context)) {
_state = state
resumeMode = MODE_ATOMIC
dispatcher.dispatch(context, this)
} else {
executeUnconfined(state, MODE_ATOMIC) {
withCoroutineContext(this.context, countOrElement) {
continuation.resumeWith(result)
}
}
}
}
....
}

解析如下:

  1. DispatchedContinuation通过类委托实现了在resumeWith()方法之前的代码逻辑添加

  2. 通过isDispatchNeeded(是否需要dispatch,Unconfined=false,default,IO=true)判断做不同处理

    1. true:调用协程的CoroutineDispatcher的dispatch方法
    2. false:调用executeUnconfined方法
    private inline fun DispatchedContinuation<*>.executeUnconfined(
    contState: Any?, mode: Int, doYield: Boolean = false,
    block: () -> Unit
    ): Boolean {
    assert { mode != MODE_UNINITIALIZED }
    val eventLoop = ThreadLocalEventLoop.eventLoop
    if (doYield && eventLoop.isUnconfinedQueueEmpty) return false
    return if (eventLoop.isUnconfinedLoopActive) {
    _state = contState
    resumeMode = mode
    eventLoop.dispatchUnconfined(this)
    true
    } else {
    runUnconfinedEventLoop(eventLoop, block = block)
    false
    }
    }
    1. 从threadlocal中取出eventLoop(eventLoop和当前线程相关的),判断是否在执行Unconfined任务
      1. 如果在执行则调用EventLoop的dispatchUnconfined方法把Unconfined任务放进EventLoop中
      2. 如果没有在执行则直接执行
    internal inline fun DispatchedTask<*>.runUnconfinedEventLoop(
    eventLoop: EventLoop,
    block: () -> Unit
    ) {
    eventLoop.incrementUseCount(unconfined = true)
    try {
    block()
    while (true) {
    if (!eventLoop.processUnconfinedEvent()) break
    }
    } catch (e: Throwable) {
    handleFatalException(e, null)
    } finally {
    eventLoop.decrementUseCount(unconfined = true)
    }
    }
    1. 执行block()代码块,即上文提到的resumeWith()
    2. 调用processUnconfinedEvent()方法实现执行剩余的Unconfined任务,知道全部执行完毕跳出循环
EventLoop
  • EventLoop是存放与threadlocal,所以是跟当前线程相关联的,而EventLoop也是CoroutineDispatcher的一个子类
internal abstract class EventLoop : CoroutineDispatcher() {
.....
//双端队列实现存放Unconfined任务
private var unconfinedQueue: ArrayQueue<DispatchedTask<*>>? = null
//从队列的头部移出Unconfined任务执行
public fun processUnconfinedEvent(): Boolean {
val queue = unconfinedQueue ?: return false
val task = queue.removeFirstOrNull() ?: return false
task.run()
return true
}
//把Unconfined任务放进队列的尾部
public fun dispatchUnconfined(task: DispatchedTask<*>) {
val queue = unconfinedQueue ?:
ArrayQueue<DispatchedTask<*>>().also { unconfinedQueue = it }
queue.addLast(task)
}
.....
}

解析如下:

  1. 内部通过双端队列实现存放Unconfined任务
    1. EventLoop的dispatchUnconfined方法用于把Unconfined任务放进队列的尾部
    2. rocessUnconfinedEvent方法用于从队列的头部移出Unconfined任务执行

3.1.4. Main

  • 是把协程运行在平台相关的只能操作UI对象的Main线程,但是根据不同平台有不同的实现
平台实现
kotlin/jskotlin对JavaScript的支持,提供了转换kotlin代码,kotlin标准库的能力,npm包管理能力
在kotlin/js上Dispatchers.Main等效于Dispatchers.Default
kotlin/native将kotlin代码编译为无需虚拟机就可运行的原生二进制文件的技术, 它的主要目的是允许对不需要或不可能使用虚拟机的平台进行编译,例如嵌入式设备或iOS
在kotlin/native上Dispatchers.Main等效于Dispatchers.Default
kotlin/JVM需要虚拟机才能编译的平台,例如Android就是属于kotlin/JVM,对于kotlin/JVM我们需要引入对应的dispatcher,例如Android就需要引入kotlinx-coroutines-android库,它里面有Android对应的Dispatchers.Main实现,其实就是把任务通过Handler运行在Android的主线程

3.2. CoroutineName Element

  • 协程名称,可以自定义,方便调试分析
public data class CoroutineName(
val name: String
) : AbstractCoroutineContextElement(CoroutineName) {

public companion object Key : CoroutineContext.Key<CoroutineName>

override fun toString(): String = "CoroutineName($name)"
}

3.3. CoroutineExceptionHandler Element

  • 协程异常处理器,默认创建的协程都会有一个异常处理器,也可以手动指定。

    var coroutineContext = Job() +
    Dispatchers.Main +
    //手动添加指定异常处理器
    CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.e(
    "CoroutineException",
    "CoroutineExceptionHandler: $throwable"
    )
    } +
    CoroutineName("asyncConcurrent")
  • 但是只对launch方法启动的根协程有效,而对async启动的根协程无效

    async启动的根协程默认会捕获所有未捕获异常并把它放在Deferred中,等到用户调用Deferred的await方法才抛出,也就是需要手动加try-catch

CASE

协程的使用场景变化自如,异常处理的情况也就比较多

  1. 非SupervisorJob情况下,字协程抛出的异常会委托给父协程的CoroutineExceptionHandler处理
    1. 子协程的CoroutineExceptionHandler并不会执行
  2. SupervisorJob情况下,不会产生异常传播,即自己的CoroutineExceptionHandler可以接收到异常
  3. 子协程同时抛出多个异常时,CoroutineExceptionHandler只会捕捉第一个异常,后续的异常存于第一个异常的suppressed数组之中
  4. 取消协程时会抛出CancellationException,但是所有的CoroutineExceptionHandler不会接收到,只能通过try-catch实现捕获

3.4. CoroutineContext结构

CoroutineContext.pngfold方法

  • 提供了从left到right遍历CoroutineContext中每一个Element的能力,并对每一个Element做operation操作
//operation是一个函数指针,可以执行函数引用
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
//对left做fold操作,把left做完fold操作的的返回结果和element做operation操作
operation(left.fold(initial, operation), element)
minusKey方法
  • 返回一个新的CoroutineContext,这个CoroutineContext删除了Key对应的Element
public override fun minusKey(key: Key<*>): CoroutineContext {
//element是否匹配,如果是则直接返回供删除,即匹配成功
element[key]?.let { return left }
//没有匹配成功则从left开始寻找
val newLeft = left.minusKey(key)
return when {
//如果left中不存在目标element,则当前CombinedContext肯定不包含目标元素,直接返回当前
newLeft === left -> this
//如果left之中存在目标element,删除目标element后,left等于空,返回当前CombinedContext的element
newLeft === EmptyCoroutineContext -> element
//如果left之中存在目标element,删除目标element后,left不等于空,创建新的CombinedContext并返回
else -> CombinedContext(newLeft, element)
}
}
结构图

CoroutineContext结构.png

  • 整体像链表,left就是指向下一个结点的指针,
  • get、minusKey操作逻辑流程都是先访问当前element,不满足,再访问left的element,顺序都是从right到left
  • fold的操作逻辑流程是先访问left,直到递归到最后的element,然后再从left到right的返回,从而访问了所有的element。

plus方法

此方法是CoroutineContext的实现,内部分为元素合并和拦截器处

  • puls方法最终返回的CoroutineContext是不存在key相同的element的,新增元素会覆盖CoroutineContext中的含有相同key的元素,这像是Set的特性

  • 拦截器的处理就是为了每次添加完成后保持ContinuationInterceptor为CoroutineContext中的最后一个元素,目的是在执行协程之前做前置操作

    CoroutineDispatcher就继承自ContinuationInterceptor

    • 通过把ContinuationInterceptor放在最后面,协程在查找上下文的element时,总能最快找到拦截器,避免了递归查找,从而让拦截行为前置执行

4. Job Element

  • 每一个所创建的协程 (通过 launch 或者 async),会返回一个 Job实例,该实例是协程的唯一标识,并且负责管理协程的生命周期

4.1. Job状态

Job在执行的过程中,包含了一系列状态,虽然开发者没办法直接获取所有状态,但是Job之中有如下三个属性

  • isActive(是否活动)
  • isCompleted(是否已完成)
  • isCancelled(是否已取消)

根据属性就可以推断出Job的所处状态,状态如下

  • 新创建 (New)
    • 当一个协程创建后就处于新建(New)状态
  • 活跃 (Active)
    • 当调用Job的start/join方法后协程就处于活跃(Active)状态
  • 完成中 (Completing)
    • 当协程执行完成后或者调用CompletableJob(CompletableJob是Job的一个子接口)的complete方法都会让当前协程进入完成中(Completing)状态
  • 已完成 (Completed)
    • 处于完成中状态的协程会等所有子协程都完成后才进入完成(Completed)状态
  • 取消中 (Cancelling)
    • 当运行出错或者调用Job的cancel方法都会将当前协程置为取消中(Cancelling)状态
  • 已取消 (Cancelled)
    • 处于取消中状态的协程会等所有子协程都完成后才进入取消 (Cancelled)状态
StateisActiveisCompletedisCancelled
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse
                                      wait children
+-----+ start +--------+ complete +-------------+ finish +-----------+
| New | -----> | Active | ---------> | Completing | -------> | Completed |
+-----+ +--------+ +-------------+ +-----------+
| cancel / fail |
| +----------------+
| |
V V
+------------+ finish +-----------+
| Cancelling | --------------------------------> | Cancelled |
+------------+ +-----------+

4.2. Job方法

fun start(): Boolean
  • 调用该函数来启动这个 Coroutine
  • 如果当前 Coroutine 还没有执行调用该函数返回 true
  • 如果当前 Coroutine 已经执行或者已经执行完毕,则调用该函数返回 false
fun cancel(cause: CancellationException? = null)
  • 通过可选的取消原因取消Job
fun invokeOnCompletion(handler: CompletionHandler): DisposableHandle
  • 通过这个函数可以给 Job 设置一个完成通知,当 Job 执行完成的时候会同步执行这个通知函数。 回调的通知对象类型为:typealias CompletionHandler = (cause: Throwable?) -> Unit.
  • CompletionHandler 参数代表了 Job 是如何执行完成的。 cause 有下面三种情况:
    • 如果 Job 是正常执行完成的,则 cause 参数为 null
    • 如果 Job 是正常取消的,则 cause 参数为 CancellationException 对象。这种情况不应该当做错误处理,这是任务正常取消的情形。所以一般不需要在错误日志中记录这种情况。
    • 其他情况表示 Job 执行失败了。
  • 这个函数的返回值为 DisposableHandle 对象,如果不再需要监控 Job 的完成情况了, 则可以调用 DisposableHandle.dispose 函数来取消监听。如果 Job 已经执行完了, 则无需调用 dispose 函数了,会自动取消监听。
suspend fun join()(suspend函数)
  • 用来在另外一个 Coroutine 中等待 job 执行完成后继续执行。

4.3. Job异常传播

  • 协程是有父子级的概念,如果子Job在运行过程之中发生异常,那么父Job就会感知到并抛出异常。如果要抑制这种行为就需要使用SupervisorJob

    除了CancellationException以外的异常

SupervisorJob
fun main(){
val parentJob = GlobalScope.launch {
//childJob是一个SupervisorJob
val childJob = launch(SupervisorJob()){
throw NullPointerException()
}
childJob.join()
println("parent complete")
}
Thread.sleep(1000)
}

此时childJob抛出异常并不会影响parentJob的运行,parentJob会继续运行并输出parent complete。


5. CoroutineScope

  • CoroutineScope是用于提供CoroutineContext的容器,但是制定了代码边界,去全局管控所有内部作用域中的CoroutineContext,源码如下:
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}

这里只需要一个CoroutineContext,保证CoroutineContext能在整个协程运行中传递下去,约束CoroutineContext的作用边界

5.1. lifecycleScope

  • lifecycleScope可以让协程具有与Activity一样的生命周期意
  • 源码解析如下:
    1. 通过创建LifecycleCoroutineScopeImpl实现CoroutineScope接口
    2. 通过SupervisorJob控制异常传播
    3. 通过Dispatchers.Main控制线程类型
    4. 在register方法中通过launch创建协程,通过lifecycle的状态监听Activity的生命周期,在合适的时机调用cancel方法
    5. 通过扩展实现取消策略

5.2. 其他方法

  • lifecycleScope还扩展出了其他作用域范围的控制函数
lifecycleScope.launchWhenCreated {  }
lifecycleScope.launchWhenStarted { }
lifecycleScope.launchWhenResumed { }

6. ContinuationInterceptor

  • ContinuationInterceptor继承于CoroutineContext.Element,也就是CoroutineContext
  • ContinuationInterceptor提供了interceptContinuation方法,实现了拦截,源码分析如下:

7. Suspend|协程状态机

  • 被suspend关键字修饰的方法为协程方法,其本质如下:

7.1. CPS机制

  • 通过CPS(Continuation-Passing-Style)机制。使得每一个suspend修饰的方法或者lambda表达式都会在代码调用的时候为其额外添加Continuation类型的参数7.2. 协程状态机

协程通过suspend来标识挂起点,但真正的挂起点还需要通过是否返回COROUTINE_SUSPENDED来判断,而代码体现是通过状态机来处理协程的挂起与恢复。在需要挂起的时候,先保留现场与设置下一个状态点,然后再通过退出方法的方式来挂起协程。在挂起的过程中并不会阻塞当前的线程。对应的恢复通过resumeWith来进入状态机的下一个状态,同时在进入下一个状态时会恢复之前挂起的现场

  • 我们结合kotlin字节码文件分析协程状态机
  1. getCOROUTINE_SUSPENDED方法也就是上文说的COROUTINE_SUSPENDED标识用于标识挂起
  2. 通过label实现不同状态的处理,在对应的case返回挂起标识
  3. 当返回了COROUTINE_SUSPENDED也就会跳出方法,此时协程就被挂起。当前线程也就可以执行其它的逻辑,并不会被协程的挂起所阻塞
  4. 最终等到下个状态,执行对应的代码
  5. 在label进入case2状态,会在对应时间字之后实现唤醒


收起阅读 »

Compose版FlowLayout了解一下~

前言 FlowLayout是开发中常用的一种布局,并且在面试时,如何自定义FlowLayout也是一个高频问题 最近Compose发布正式版了,本文主要是以FlowLayout为例,熟悉Compose自定义Layout的主要流程 本文主要要实现以下效果: ...
继续阅读 »

前言


FlowLayout是开发中常用的一种布局,并且在面试时,如何自定义FlowLayout也是一个高频问题
最近Compose发布正式版了,本文主要是以FlowLayout为例,熟悉Compose自定义Layout的主要流程
本文主要要实现以下效果:



  1. 自定义Layout,从左向右排列,超出一行则换行显示

  2. 支持设置子View间距及行间距

  3. 当子View高度不一致时,支持一行内居上,居中,居下对齐


效果


首先来看下最终的效果


Compose自定义Layout流程


Android View体系中,自定义Layout一般有以下几步:



  1. 测量子View宽高

  2. 根据测量结果确定父View宽高

  3. 根据需要确定子View放置位置


Compose中其实也是大同小异的
我们一般使用Layout来测量和布置子项,以实现自定义Layout,我们首先来实现一个自定义的Column,如下所示:


@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
)
{
Layout(
modifier = modifier,
children = content
) { measurables, constraints ->
// 测量布置子项
}
}

Layout中有两个参数,measurables 是需要测量的子项的列表,而constraints是来自父项的约束条件


@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
)
{
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
//需要测量的子项
val placeables = measurables.map { measurable ->
// 1.测量子项
measurable.measure(constraints)
}

// 2.设置Layout宽高
layout(constraints.maxWidth, constraints.maxHeight) {
var yPosition = 0

// 在父Layout中定位子项
placeables.forEach { placeable ->
// 3.在屏幕上定位子项
placeable.placeRelative(x = 0, y = yPosition)

// 记录子项的y轴位置
yPosition += placeable.height
}
}
}
}

以上主要就是做了三件事:



  1. 测量子项

  2. 测量子项后,根据结果设置父Layout宽高

  3. 在屏幕上定位子项,设置子项位置


然后一个简单的自定义Layout也就完成了,可以看到,这跟在View体系中也没有什么区别
下面我们来看下怎么实现一个FlowLayout


自定义FlowLayout


我们首先来分析下,实现一个FlowLayou需要做些什么?



  1. 首先我们应该确定父Layout的宽度

  2. 遍历测量子项,如果宽度和超过父Layout则换行

  3. 遍历时同时记录每行的最大高度,最后高度即为每行最大高度的和

  4. 经过以上步骤,宽高都确定了,就可以设置父Layout的宽高了,测量步骤完成

  5. 接下来就是定位,遍历测量后的子项,根据之前测量的结果确定其位置


流程大概就是上面这些了,我们一起来看看实现


遍历测量,确定宽高


    Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val parentWidthSize = constraints.maxWidth
var lineWidth = 0
var totalHeight = 0
var lineHeight = 0
// 测量子View,获取FlowLayout的宽高
measurables.mapIndexed { i, measurable ->
// 测量子view
val placeable = measurable.measure(constraints)
val childWidth = placeable.width
val childHeight = placeable.height
//如果当前行宽度超出父Layout则换行
if (lineWidth + childWidth > parentWidthSize) {
//记录总高度
totalHeight += lineHeight
//重置行高与行宽
lineWidth = childWidth
lineHeight = childHeight
totalHeight += lineSpacing.toPx().toInt()
} else {
//记录每行宽度
lineWidth += childWidth + if (i == 0) 0 else itemSpacing.toPx().toInt()
//记录每行最大高度
lineHeight = maxOf(lineHeight, childHeight)
}
//最后一行特殊处理
if (i == measurables.size - 1) {
totalHeight += lineHeight
}
}

//...设置宽高
layout(parentWidthSize, totalHeight) {

}
}

以上就是确定宽高的代码,主要做了以下几件事



  1. 循环测量子项

  2. 如果当前行宽度超出父Layout则换行

  3. 每次换行都记录每行最大高度

  4. 根据测量结果,最后确定父Layout的宽高


记录每行的子项与每行最大高度


上面我们已经测量完成了,明确了父Layout的宽高
不过为了实现当子项高度不一致时居中对齐的效果,我们还需要将每行的子项与每行的最大高度记录下来


    Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val mAllPlaceables = mutableListOf<MutableList<Placeable>>() // 所有子项
val mLineHeight = mutableListOf<Int>() //每行的最高高度
var lineViews = mutableListOf<Placeable>() //每行放置的内容
// 测量子View,获取FlowLayout的宽高
measurables.mapIndexed { i, measurable ->
// 测量子view
val placeable = measurable.measure(constraints)
val childWidth = placeable.width
val childHeight = placeable.height
//如果行宽超出Layout宽度则换行
if (lineWidth + childWidth > parentWidthSize) {
//每行最大高度添加到列表中
mLineHeight.add(lineHeight)
//二级列表,存放所有子项
mAllPlaceables.add(lineViews)
//重置每行子项列表
lineViews = mutableListOf()
lineViews.add(placeable)
} else {
//每行高度最大值
lineHeight = maxOf(lineHeight, childHeight)
//每行的子项添加到列表中
lineViews.add(placeable)
}
//最后一行特殊处理
if (i == measurables.size - 1) {
mLineHeight.add(lineHeight)
mAllPlaceables.add(lineViews)
}
}
}

上面主要做了三件事



  1. 每行的最大高度添加到列表中

  2. 每行的子项添加到列表中

  3. lineViews列表添加到mAllPlaceables中,存放所有子项


定位子项


上面我们已经完成了测量,并且获得了所有子项的列表,现在可以遍历定位了


@Composable
fun ComposeFlowLayout(
modifier: Modifier = Modifier,
itemSpacing: Dp = 0.dp,
lineSpacing: Dp = 0.dp,
gravity: Int = Gravity.TOP,
content: @Composable () -> Unit
)
{
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
layout(parentWidthSize, totalHeight) {
var topOffset = 0
var leftOffset = 0
//循环定位
for (i in mAllPlaceables.indices) {
lineViews = mAllPlaceables[i]
lineHeight = mLineHeight[i]
for (j in lineViews.indices) {
val child = lineViews[j]
val childWidth = child.width
val childHeight = child.height
// 根据Gravity获取子项y坐标
val childTop = getItemTop(gravity, lineHeight, topOffset, childHeight)
child.placeRelative(leftOffset, childTop)
// 更新子项x坐标
leftOffset += childWidth + itemSpacing.toPx().toInt()
}
//重置子项x坐标
leftOffset = 0
//子项y坐标更新
topOffset += lineHeight + lineSpacing.toPx().toInt()
}
}
}
}

private fun getItemTop(gravity: Int, lineHeight: Int, topOffset: Int, childHeight: Int): Int {
return when (gravity) {
Gravity.CENTER -> topOffset + (lineHeight - childHeight) / 2
Gravity.BOTTOM -> topOffset + lineHeight - childHeight
else -> topOffset
}
}

要定位一个子项,其实就是确定它的坐标,以上主要做了以下几件事



  1. 遍历所有子项

  2. 根据位置确定子项XY坐标

  3. 根据Gravity可使子项居上,居中,居下对齐


综上,一个简单的ComposeFlowLayout就完成了


总结


本文主要实现了一个支持设置子View间距及行间距,支持子View居上,居中,居左对齐的FlowLayout,了解了Compose自定义Layout的基本流程
后续更多Compose相关知识点,敬请期待~


本文的所有相关代码


Compose版FlowLayout


收起阅读 »

View.post和Handler.post的关系

前言 View.post和Handler.post是Android开发中经常使用到的两个”post“方法,我们经常通过前者去获取一些View在运行时的渲染数据,或者测量页面的渲染时间。而后者则是Android的核心Handler的一个方法,它会向对应线程的M...
继续阅读 »

前言


View.post和Handler.post是Android开发中经常使用到的两个”post“方法,我们经常通过前者去获取一些View在运行时的渲染数据,或者测量页面的渲染时间。而后者则是Android的核心Handler的一个方法,它会向对应线程的MessageQueue中插入一条Message,在未来的某个事件点得到执行.....


为什么要拿这二者来比较?


首先,这二者的名字相同


其次,是View.post()的调用时机和整个View的绘制和渲染有着千丝万缕的联系。而这一切的基础,正是主线程的Handler.post(),理清这二者的关系,能够加深我们对View渲染、绘制的流程的理解。


View的渲染起点


宏观上来说,当DecorView被”attach“到Window之上后,程序能够收到系统分配给各个Activity的同步信号时,View就会开始渲染了,当每个同步信号到来时,ChoreoGrapher将会派发出一个信号通知ViewRootImpl进行视图的渲染,因此,从系统上来看,每次释放的Vsync同步信号应该是视图绘制的起点。


从App端来说,当ScheduleTravesals被调用时,会先向MessageQueue中插入一个消息屏障,此时会阻隔其他的同步消息的通过,允许异步消息的进入。然后mChoreoGrapher,向MessageQueue中插入一个视图更新的信号,最终会走到doTraversals()方法中,在该方法的执行过程中,将会先取消掉同步屏障,然后紧接着执行performTraversals()方法。显然,消息屏障的作用就是提升peformTraversals的优先级,确保视图的优先绘制。


不难发现,真正的进行渲染的起点是perfromTraversals()方法:


图片1.png


View.post的执行流程


View.post在不同版本的Android系统中,有着不同的实现,在API24以前,View.post所做的是:当View.post被调用时,直接向ViewRootImpl的mRunQueue中插入一个Runnable,然后在performTraversals()过程中,统一进行处理,这样一来,View.post()就会按照View.post()的调用顺序在”未来的某个时间点“进行执行,这说明:在这一系列的Android版本中,View.post的执行顺序就是本身调用View.post()的顺序




  1. 处理:这里的处理并非直接执行Runnable,而是统一插入到主线程的MessageQueue中去执行;

  2. “未来的某个时间点”,这个未来的某个时间点指的是perfromTraversals()中将ViewRootImpl中mRunQueue中的所有Runnable插入到MessageQueue之后的某个时间点。必然在performTraversals()之后。



图片2.png


如上图,必须得等到整个perfromTraversals方法体执行完成(包括)后,才有可能执行下一个Message(这里标注为了Runnable),而perfromTraversals()方法体中,会顺序地调用performMeasure()、performLayout()、performDraw()方法,这三个方法走完,意味着视图已经完成了渲染,此时的View.post()执行,必然是能落在视图创建之后


而API24及之后的版本中,View.post所做的事情发生了改变,当View.post()调用时,Runnable被插入到View各自的mRunQueue当中,也就是说,每个View都含有一个mRunQueue,当performTraversals()中,也没有统一处理了,而是根据 performTraversals()->dispatchAttachedToWindows()递归地调用到子View时,子View将自己的mRunQueue插入到主线程的MessageQueue,这意味着:在高版本的执行过程中,View.post()的执行顺序是按照视图被迭代到的顺序。


不变的是View.post()执行,必然是能落在视图创建之后,这也是为什么能够调用View.post()来获取一些屏幕上的View的数据的原因。


Handler.post()能像View.post()一样获取到宽、高数据吗?


Activity为我们暴露了三个常用的生命周期函数:onCreate()、onStart()、onResume()。通常我们对一些事件的监听、View的初始化设置都会在这三个生命周期函数中实现,以最后执行的onReumse()为例,我们在其中使用主线程的Handler.post()获取一个视图的数据,我们可以看看结果:


    override fun onResume(){
super.onResume()
Handler(Looper.getMainLooper()).post{
Log.d("getHeight",textView.height.toString())
}
}

  D/getHeight: 0


显然,失败了。


我们知道,一个的Activity的创建初期,DecorView并不会直接就和Activity建立联系,建立联系的过程在handleResumeActivity()当中,此时的DecorView被attach到了Activity之上。但是,我们需要明确一点:一个View如果没有和Activity建立联系,那么它将收不到系统的同步信号,也就无法更新(更新也没有意义,因为它没有地方去显示),我们看看handleResumeActiivty的执行方法体,可以发现,先走了onResume()的回调,再走了a.mDecor = decor这一步骤,上文我们提到,视图更新的事件是以Message的形式,在MessageQueue中”排队“的,如果我们在onResume()中插入一个消息去获取渲染之后的宽高数据,那么这时的MessageQueue大概是这样:


image.png


当前正在执行的是黄色的Message,这是一个从ActivityThread.java中H类发出的调度方法,它将会调用到handleResumeActivity中的一系列方法,最终走到onResume这,我们使用Handler.post(),我们会发现消息被插在了黄色的Message之后,但是此时的a.mDecor = decor还没有执行,更不可能已经发生绘制了,这也就意味着压根没渲染,没视图,自然也没数据,完整的流程如下:


image.png


end~


收起阅读 »

搞懂Socket通信(一)

搞懂Socket通信(一) Socket 在编程中并不陌生,即时通信、推送的应用场景也都是用到它。对于做Android的小伙伴来说,自己也很少的去写底层的逻辑,相应的是去使用第三方的开源框架,当然在一般场景下,第三方基本都能满足需求了,但对于极端的场景...
继续阅读 »



搞懂Socket通信(一)



Socket 在编程中并不陌生,即时通信、推送的应用场景也都是用到它。对于做Android的小伙伴来说,自己也很少的去写底层的逻辑,相应的是去使用第三方的开源框架,当然在一般场景下,第三方基本都能满足需求了,但对于极端的场景,当第三方框架满足不了的时候,还是需要自己去了解它。



一、七层协议


开放式系统互联通信参考模型(英语:Open System Interconnection Reference Model,缩写为 OSI),简称为OSI模型,是国际标准化组织提出。


在OSI模型中,通信分为七层模型


简单一点,也可以分为TCP/IP四层模型,或者五层模型,看图更方便理解。


1.png


图片来源


我们知道:IP协议对应网络层、TCP协议和UDP协议对应传输层、HTTP协议对应应用层


那么Socket在哪里?


2.jpg


图片来源


二、Socket 是什么


Socket 不是协议,是应用层与TCP/IP协议族通信的中间软件抽象层,是一组接口。


它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。


我们仅需要操作Socket来处理数据,而无需关心复杂的TCP/IP


三、有哪些基于Socket的协议


Socket是应用层与TCP/IP协议族通信的中间软件抽象层。


所以基于TCP/IP的协议都是基于Socket


比如HTTP协议,是基于TCP/IP协议之上的应用层协议


3.1 疑问


既然都是基于TCP/IP,那么我们知道Http访问完毕之后,是一次性访问,为什么我们用Socket实现TCP却是长连接,以流的形式发送数据呢。


3.2 解答


这是因为Http在请求时去建立TCP连接,完毕之后,就释放了TCP连接。


下面这个例子可以诠释http的流程。


例如:在浏览器地址栏键入URL,按下回车之后会经历以下流程:



  1. 浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址;


  2. 解析出 IP 地址后,根据该 IP 地址和默认端口 80,和服务器建立TCP连接;


  3. 浏览器发出读取文件(URL 中域名后面部分对应的文件)的HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;


  4. 服务器对浏览器请求作出响应,并把对应的 html 文本发送给浏览器;


  5. 释放 TCP连接;


  6. 浏览器将该 html 文本并显示内容;  



三、Android中长连接框架



  1. Apache MINA


  2. OKHttp中的 webSocket


  3. socketIo



...


这是笔者在开发安卓中使用过的长连接框架


后来才开始自己封装Socket,自己实现心跳机制、断线重连机制。


下一篇讲如何实现。

收起阅读 »

图文记录HTTPS知识点

底层网络监测工具:Wireshark一、名词全称HTTPSHTTP Secure/HTTP over SSL / HTTP over TLSSSLSecure Socket Layer :安全套接字层TLSTransport Layer Security:安全...
继续阅读 »

TLS链接建立时序图.jpg

底层网络监测工具:Wireshark

一、名词全称

  • HTTPS

HTTP Secure/HTTP over SSL / HTTP over TLS

  • SSL

Secure Socket Layer :安全套接字层

  • TLS

Transport Layer Security:安全传输层

TLS 是 SSL的前身,在HTTPS中指的是TLS

二、HTTP+加TLS后的传输层级示意

发送:HTTP -> TLS -> TCP -> IP -> LINK

接受:LINK -> IP-> TCP -> TLS -> HTTP

简单总结:传输加一层TLS加密层,接受加一层TLS解密层

三、本质

在客户端和服务器之间使用非对称加密协商出一套对称密钥,每次发送信息之前对内容进行加密,收到之后进行解密,实现加密传输。

  • 为什么不直接使用非对称加密?

非对称加密属于复杂型算法,会严重降低接受和发送的性能,降低处理效率。

四、TLS 链接流程

1.Client发送:Client Hello

  • 1.TLS 版本
  • 2.加密套件:对称加密算法、非对称加密算法、hash 算法
  • 3.客户端随机数
  • 4.其他信息

2.Server发送: ServerHello

  • 1.选出可匹配的TLS版本
  • 2.选出加密套件:对称加密算法、非对称加密算法、hash 算法
  • 3.其他

3.Server发送:服务器证书

  • 1.服务器证书(包含证书签名,公钥、主机名,地区等信息)
  • 2.证书签发机构证书信息(证书公钥,与公钥签名)
  • 3.指定根证书信息
  • 4.其他信息

验证规则:

  • 1.使用证书签发机构的私钥,对服务器的公钥的hash值进行签名。对应证书签发机构的公钥可以解开这个签名,确认服务器证书安全可信。
  • 2.使用指定的根证书的公钥,可以解开证书签发机构证书公钥的签名,确认签发机构安全可信
  • 到达客户端后,客户端验证链:
    • 1.客户端向系统确认服务器指定的根证书是否可信,可信则使用根证书中的公钥,解密服务器证书的签发机构证书的公钥签名
    • 2.使用签发机构的公钥解密服务器证书的公钥的签名,确认服务器证书安全可信

以上有一个验证环节未通过,那么TLS链接就建立失败。

服务器的证书只是证明网站或域名所有者,而不能证明是否合法

用Wireshark对建立HTTPS链接传输信息展示: image.png

4.Client 连发3个消息:

  • Pre-master secret(使用服务器公钥加密)
  • 将开始传输加密消息
  • Finished(加密后的握手信息)

image.png

服务端使用Pre-master secret按照相同的计算方式得出跟客户端一样的加密密钥

到此阶段,两端都将会持有以下信息:

1.客户端随机数

2.服务器随机数

3.Pre-master secret 随机数

4.Master secret(两端使用相同的算法,计算上面的三个信息,得出相同的结果)

用Master secret计算出下面的对称密钥:

5.客户端加密密钥(对称密钥)

6.服务器加密密钥(对称密钥)

7.客户端MAC secret

8.服务端MAC secret

到此两方都创建出了用于对称加密的传输的密钥

5.Server 连发2个消息:

  • 将开始传输加密消息
  • Finished(加密后的握手信息)

时序上并不一定在客户端最后两条消息之后

image.png

将上面确认的信息 通过用加密方式发送给客户端,客户端对数据进行验证,确认两方一致。


至此一共五个大步骤,都顺利完成TLS链接就建立成功,可以使用对应的对称加密密钥进行加密传输了。

五、问:

1.服务器随机数作用?

避免replay attack:重放攻击 虽然解不开加密信息,但是将发送的加密数据拦截后重复发送给服务器,来搞破坏,比如拦截的是转账请求,换个时间段继续重放请求,多次转账。 每次链接后拿到的服务器随机数都不一样,避免被重放攻击

2.既然是对称加密,为什么客户端于服务端要用不同的加密密钥?

为了避免发送出去的数据被原封不动的又被发回来,发和收使用不同的密钥,避免这种攻击

3.既然HTTPS都用上了,为什么有时候项目上还要内部再搞一套内部加密逻辑

这个问题是个大坑,以至于从理解上就容易产生误区。

  • 第一如果使用的证书不能100%信任,比如证书签发机构可能出卖用户、卖国、被恶势力控制。这时候整个HTTPS就彻底被瓦解,毫无意义。

逻辑是这么个逻辑,但在国内基本是无稽之谈!国外就不知道了。

真的不信任的话用私有证书,比起自己订一套不如TLS安全的加密逻辑来得保险得多。

当然这只是我个人的理解,也许有其他的知识面,欢迎评论提出。

  • 第二HTTPS 只是传输层的加密,防的是中间人攻击。所以服务器很被动,终端伪造证书被根证书信任后都可以访问他。

所以C端个体被破解后,可以对服务端造成小范围破坏,或者有大范围破坏的风险

比如HTTPS抓包,导致单用户可授权访问的接口请求参数,返回参数都被窃取,造成数据安全,和特定接口被攻击的风险。

因此服务端需要鉴别基于某些端信任私有证书后在业务层面的非法请求,以及单用户的重放攻击(replay attack)

那么可以考虑:

1.使用私有证书,但是这会给C端带来极高的运营成本,毕竟每次更新证书都要更新C端版本

2.使用对称加密,每次请求都生成一个新的请求签名。这样第一可以防重放攻击,第二服务器可以鉴别访问者是否合法。

3.使用非对称加密先加密原始数据,再丢给HTTPS进行传输。这样抓包也不担心,破解也不担心。只需权衡性能方面的损失,通常用于支付等极度敏感的数据交互场景上。


END

收起阅读 »

LiveData奇思妙用总结

前言 本文不涉及LiveData的基本使用方式。 阅读本文之前,强推推荐先看官方文档 LiveData的概览,官方文档写的非常好,并且很详细。 本文是一篇总结文,自己的一些使用结总结以及网上的学习归纳。 一、LiveData结...
继续阅读 »

前言



  • 本文不涉及LiveData的基本使用方式。


  • 阅读本文之前,强推推荐先看官方文档 LiveData的概览,官方文档写的非常好,并且很详细。


  • 本文是一篇总结文,自己的一些使用结总结以及网上的学习归纳。



一、LiveData结合ActivityResult


对 Activity Results Api不怎么了解的,可以先看下官方文档:


developer.android.com/training/ba…


1.1 调用系统相机


场景


调用系统相机,获取拍照后返回的照片


示例代码


// MainActivity.kt
private var takePhotoLiveData: TakePhotoLiveData = TakePhotoLiveData(activityResultRegistry, "key")

// 点击拍照按钮
mBinding.btTakePhoto.setOnClickListener {
takePhotoLiveData.takePhoto()
}

// 拍照返回的照片
takePhotoLiveData.observe(this) { bitmap ->
mBinding.imageView.setImageBitmap(bitmap)
}

几行代码搞定调用系统相机并且返回拍照后的图片。


封装示例


class TakePhotoLiveData(private val registry: ActivityResultRegistry, private val key: String) :
LiveData<Bitmap>() {

private lateinit var takePhotoLauncher: ActivityResultLauncher<Void?>

override fun onActive() {
takePhotoLauncher = registry.register(key, ActivityResultContracts.TakePicturePreview()) { result ->
value = result
}
}

override fun onInactive() = takePhotoLauncher.unregister()

fun takePhoto() = takePhotoLauncher.launch(null)

}

同理,请求权限也可以类似封装:


1.2 请求权限


场景


请求系统权限,例如GPS定位


示例代码


private var requestPermissionLiveData = RequestPermissionLiveData(activityResultRegistry, "key")

mBinding.btRequestPermission.setOnClickListener {
requestPermissionLiveData.requestPermission(Manifest.permission.RECORD_AUDIO)
}

requestPermissionLiveData.observe(this) { isGranted ->
toast("权限RECORD_AUDIO请求结果 $isGranted")
}

封装的代码跟上面类似,就不列出来了。


二、LiveData实现全局定时器


场景


一个全局计数器,Activity销毁时,计时器停止,不会导致内存泄露,Activity激活时,计时器开始,自动获取最新的计时。


示例代码


// 开启计时器
TimerGlobalLiveData.get().startTimer()

// 停止计时器
TimerGlobalLiveData.get().cancelTimer()

// 全局监听
TimerGlobalLiveData.get().observe(this) {
Log.i(TAG, "GlobalTimer value: == $it")
}

封装示例


class TimerGlobalLiveData : LiveData<Int>() {

private val handler: Handler = Handler(Looper.getMainLooper())

private val timerRunnable = object : Runnable {
override fun run() {
postValue(count++)
handler.postDelayed(this, 1000)
}
}

fun startTimer() {
count = 0
handler.postDelayed(timerRunnable, 1000)
}

fun cancelTimer() {
handler.removeCallbacks(timerRunnable)
}

companion object {
private lateinit var sInstance: TimerGlobalLiveData

private var count = 0

@MainThread
fun get(): TimerGlobalLiveData {
sInstance = if (::sInstance.isInitialized) sInstance else TimerGlobalLiveData()
return sInstance
}
}

}

三、共享数据


场景



  • 多个Fragment之间共享数据


  • Activity和Fragment共享数据


  • Activity/Fragment和自定义View共享数据



获取ViewModel实例时都用宿主Activity的引用即可。


示例代码


// Activity中
private val mViewModel by viewModels<ApiViewModel>()

// Fragment中
private val mViewModel by activityViewModels<ApiViewModel>()

// 自定义View中
fun setHost(activity: BaseActivity) {
var viewModel = ViewModelProvider(activity).get(ApiViewModel::class.java)
}

四、对于自定义View


关于自定义View,提一下我常用的方式。


通过ViewMode跟LiveData把自定义view从Activity中独立开来,自成一体,减少在Activity中到处调用自定义View的引用。


场景


Activity中有一个EndTripView自定义View,这个自定义View中有很多的小view,最右下角是一个按钮,点击按钮,调用结束行程的网络请求。


img


以前的做法是自定义View通过callback回调的方式将点击事件传递给Activity,在Activity中请求结束行程的接口,然后Activity中收到回调后,拿着自定义View的引用进行相应的ui展示


示例伪代码


// TestActivity
class TestActivity{
private lateinit var endTripView : EndTripView
private val endTripViewModel by viewModels<EndTripViewModel>()

fun onCreate{
endTripView = findViewById(R.id.view_end_trip)
endTripView.setListener{

onClickEndTrip(){
endTripViewModel.endTrip()
}
}
endTripViewModel.endTripLiveData.observer(this){ isSuccess ->
if(isSuccess){
endTripView.showEndTripSuccessUi()
}else {
endTripView.showEndTripFailedUi()
}
}
}
}

从上面伪代码中可以看到:



  • 操作逻辑都在Activity中,Activity中存在很多自定义View的回调,并且Activity中很多地方都有EndTripView的引用。


  • 自定义EndTripView需要定义很多的回调和公开很多的操作方法。


  • 如果业务很复杂,那么Activity会变得很臃肿并且不好维护。


  • 并且自定义EndTripView也严重依赖Activity,如果想在其他地方用,需要copy一份代码。



优化后伪代码


// Activity中代码
fun onCreate{
endTripView = findViewById(R.id.view_end_trip)
endTripView.setHost(this)
endTripViewModel.endTripLiveData.observer(this){ isSuccess ->
// 更新Activity的其它ui操作
}
}

// 自定义View中
class EndTripView : LinearLayout{

private var endTripViewModel: EndTripViewModel? = null

fun setHost(activity: BaseActivity) {
endTripViewModel = ViewModelProvider(activity).get(EndTripViewModel::class.java)
endTripViewModel.endTripLiveData.observer(this){ isSuccess ->
if(isSuccess){
showEndTripSuccessUi()
}else {
showEndTripFailedUi()
}
}
}

private fun clickEndTrip{
endTripViewModel?.endTrip()
}

private fun showEndTripSuccessUi(){...}

private fun showEndTripFailedUi(){...}
}

把自定义View相关的逻辑封装在自定义View里面,让自定义View成为一片独立的小天地,不再依赖Activity,这样Activity中的代码就非常简单了,自定义View也可以将方法都私有,去掉一些callback回调,实现高内聚。


并且由于LiveData本身的特效,跟Activity的生命周期想关联,并且点击结束行程按钮,Activity中如果注册了相应的LiveData,也可以执行相应的操作。


这样就把跟结束行程有关的自定义View的操作和ui更新放在自定义View中,Activity有关的操作在Activity中,相互隔离开来。


如果Activity中的逻辑不复杂,这种方式看不出特别的优势,但是如果Activity中逻辑复杂代码很多,这种方式的优点就很明显了。


五、LiveData实现自动注册和取消注册


利用LiveDatake可以感受Activity生命周期的优点,在Activity销毁时自动取消注册,防止内存泄露。


场景


进入Activity时请求定位,Activity销毁时移除定位,防止内存泄露


以前的方式


// 伪代码··
class MainActiviy {

override fun onStart() {
super.onStart()
LocationManager.register(this)
}

override fun onStop() {
super.onStop()
LocationManager.unRegister(this)
}
}

示例代码


val locationLiveData = LocationLiveData()
locationLiveData.observe(this){location ->
Log.i(TAG,"$location")
}

封装示例


class LocationLiveData : LiveData<Location>() {

private var mLocationManager =
BaseApp.instance.getSystemService(LOCATION_SERVICE) as LocationManager

private var gpsLocationListener: LocationListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
postValue(location)
}

override fun onProviderDisabled(provider: String) = Unit
override fun onProviderEnabled(provider: String) = Unit
override fun onStatusChanged(provider: String, status: Int, extras: Bundle) = Unit
}

override fun onActive() {
mLocationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, minTimeMs, minDistanceM, gpsLocationListener
)
}

override fun onInactive() {
mLocationManager.removeUpdates(gpsLocationListener)
}
}

当然,使用自定义的LifecycleObserver是一样的


class LocationObserver : LifecycleObserver {

@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun startLoaction() {

}

@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun stopLocation() {
...
}
}

myLifecycleOwner.getLifecycle().addObserver(LocationObserver())

具体见官方文档:


developer.android.com/topic/libra…


查看下LiveData的源码就知道,匿名内部类里面也是继承LifecycleObserver


六、LiveData 结合 BroadcastReceiver


场景


可以实现BroadcastReceiver的自动注册和取消注册,减少重复代码。


封装代码


class NetworkWatchLiveData : LiveData<NetworkInfo?>() {
private val mContext = BaseApp.instance
private val mNetworkReceiver: NetworkReceiver = NetworkReceiver()
private val mIntentFilter: IntentFilter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)

override fun onActive() {
mContext.registerReceiver(mNetworkReceiver, mIntentFilter)
}

override fun onInactive() = mContext.unregisterReceiver(mNetworkReceiver)

private class NetworkReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val manager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = manager.activeNetworkInfo
get().postValue(activeNetwork)
}
}

companion object {

private lateinit var sInstance: NetworkWatchLiveData

@MainThread
fun get(): NetworkWatchLiveData {
sInstance = if (::sInstance.isInitialized) sInstance else NetworkWatchLiveData()
return sInstance
}
}
}

七、LiveEventBus


场景


封装LiveData替换EventBus,实现消息总线,可以减少引入第三方库。


项目地址


github.com/JeremyLiao/…


实现原理


Android消息总线的演进之路:用LiveDataBus替代RxBus、EventBus


八、LiveData数据倒灌解决


发生原因


什么是LiveData数据倒灌?为什么会导致数据倒灌?


附上我以前写的一篇文章?


Activity销毁重建导致LiveData数据倒灌


解决办法



九、Application级别的ViewModel


场景


ViewModel不属于Activity或者Fragment所有,属于Application级别的


示例代码


protected <T extends ViewModel> T getApplicationScopeViewModel(@NonNull Class<T> modelClass) {
if (mApplicationProvider == null) {
mApplicationProvider = new ViewModelProvider((BaseApplication) this.getApplicationContext(),
getAppFactory(this));
}
return mApplicationProvider.get(modelClass);
}

private ViewModelProvider.Factory getAppFactory(Activity activity) {
Application application = checkApplication(activity);
return ViewModelProvider.AndroidViewModelFactory.getInstance(application);
}

项目地址


具体见KunMin大神的:


github.com/KunMinX/Jet…


十、LiveData的转换


场景


获取用户信息的接口返回的是一个User对象,但是页面上只需要显示用户的名字UserName,这样就没必要把整个User对象抛出去。


private val userLiveData: LiveData<User> = UserLiveData()
val userName: LiveData<String> = Transformations.map(userLiveData) {
user -> "${user.name} ${user.lastName}"
}

摘自官方文档:developer.android.com/topic/libra…


此外,还有一种转换方式:Transformations.switchMap(),具体见官方文档。


十一、合并多个LiveData数据源


场景


如果界面中有可以从本地数据库或网络更新的 LiveData 对象,则可以向 MediatorLiveData 对象添加以下源:



  • 与存储在数据库中的数据关联的 LiveData 对象。

  • 与从网络访问的数据关联的 LiveData 对象。


来自官方文档:developer.android.com/topic/libra…


示例代码


// 数据库来的结果
private val dbLiveData = StateLiveData<List<WxArticleBean>>()
// api网络请求的结果
private val apiLiveData = StateLiveData<List<WxArticleBean>>()
// 将上面两个结果进行合并,只有有一个更新,mediatorLiveDataLiveData就会收到
val mediatorLiveDataLiveData = MediatorLiveData<ApiResponse<List<WxArticleBean>>>().apply {
this.addSource(apiLiveData) {
this.value = it
}
this.addSource(dbLiveData) {
this.value = it
}
}

代码地址


github.com/ldlywt/Fast…


鸣谢


本文是一片总结文,会长期不定时更新。


如果有其他的LiveData奇思妙用,请留言,非常感谢。


最后,感谢网上各路大神的无私奉献。


收起阅读 »

autojs正经的交互-安卓与webview

效果展示缘起我一直觉得现在的autojs和webview交互不正经,监听弹框监听console日志监听网页title监听url尤其是监听弹框, 直接dismiss, 那那些需要弹框的网页怎么办?环境Autojs版本: 9.0.4Android版本: 8.0.0...
继续阅读 »

效果展示

效果.gif

缘起

我一直觉得现在的autojs和webview交互不正经,

  • 监听弹框
  • 监听console日志
  • 监听网页title
  • 监听url

尤其是监听弹框, 直接dismiss, 那那些需要弹框的网页怎么办?

环境

Autojs版本: 9.0.4

Android版本: 8.0.0

思路

  • autojs作为发出命令方, webview作为执行命令方, 使用方法: webView.evaluateJavascript
  • webview作为发出命令方, autojs作为执行命令方, 使用方法: webView.addJavascriptInterface

你将学到以下知识点

  • 获取随机颜色
  • 改变网页背景色
  • 改变按钮背景色
  • evaluateJavascript的回调函数
  • addJavascriptInterface的回调函数
  • @JavascriptInterface注解的使用
  • java类的内部interface

代码讲解

1. 创建类JSInterface, 然后打包成dex给autojs调用
package com.yashu.simple;

import android.webkit.JavascriptInterface;

public class JSInterface {
private JSCallback jsCallback;

public JSInterface setJsCallback(JSCallback jsCallback) {
this.jsCallback = jsCallback;
return this;
}


@JavascriptInterface
public void share(String callback) {
if (jsCallback != null) {
jsCallback.jsShare(callback);
}
}


public interface JSCallback {
void jsShare(String callback);
}
}
2. 网页
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<script language="javascript">
function onButtonClick() {
function randomHexColor() {
//随机生成十六进制颜色
return "#" + ("00000" + ((Math.random() * 0x1000000) << 0).toString(16)).substr(-6);
}
let color = randomHexColor();
jsInterface.share(color);
}
</script>
</head>

<body>
<img
id="image"
width="328"
height="185"
src="https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png"
/>

<button type="button" style="width: 328px; height: 185px; font-size: 40px" onclick="onButtonClick()">
改变安卓按钮颜色
</button>
</body>
</html>
3. 加载dex并导入类JSInterface
let dexPath = files.path("./classes2.dex");
runtime.loadDex(dexPath);
importClass(android.webkit.JavascriptInterface);
importClass(android.webkit.WebViewClient);
importClass(android.webkit.ValueCallback);
importClass(android.webkit.WebChromeClient);
importClass(com.yashu.simple.JSInterface);
4. UI界面
ui.layout(
<vertical>
<text text="牙叔教程 简单易懂" textSize="28sp" textColor="#fbfbfe" bg="#00afff" w="*" gravity="center"></text>
<webview id="webview" />
<button id="button" text="改变网页body颜色" />
</vertical>
);
5. 设置webview属性
let webView = ui.findById("webview");
webView.getSettings().setJavaScriptEnabled(true);
6. 设置按钮点击事件
// 发出命令方: Android
// 执行命令方: Html
// Html返回值: body背景色
ui.button.click(function () {
function test() {
function randomHexColor() {
//随机生成十六进制颜色
return "#" + ("00000" + ((Math.random() * 0x1000000) << 0).toString(16)).substr(-6);
}
document.body.bgColor = randomHexColor();
return "牙叔教程, " + document.body.bgColor;
}
let js = test.toString() + ";test();";
let valueCallback = new ValueCallback({
onReceiveValue: function (value) {
toastLog("网页返回值: " + value);
},
});
webView.evaluateJavascript(js, valueCallback);
});
7.网页日志日志打印到控制台, 方便查看错误
webView.setWebChromeClient(
new JavaAdapter(WebChromeClient, {
onConsoleMessage: function (message) {
message.message && log("h5: " + message.message());
},
})
);
8. JSInterface的使用方法
// 重点, 这里给html注册了一个全局对象: jsInterface
webView.addJavascriptInterface(new JSInterface().setJsCallback(new JSCallback()), "jsInterface");
9. 加载网页
html = files.path("./index.html");
webView.loadUrl("file://" + html);

名人名言

收起阅读 »

Objective-C 动态方法决议

一、动态方法决议当imp没有找到的时候的时候会赋值libobjc.A.dylib_objc_msgForward_impcache`,首先会进入如下代码逻辑: if (slowpath(behavior & LOOKUP_RESOLVER)) { ...
继续阅读 »

一、动态方法决议

imp没有找到的时候的时候会赋值libobjc.A.dylib_objc_msgForward_impcache`,首先会进入如下代码逻辑:

  if (slowpath(behavior & LOOKUP_RESOLVER)) {
behavior ^= LOOKUP_RESOLVER;
//要查找的对象,方法,类,1
return resolveMethod_locked(inst, sel, cls, behavior);
}
  • 这其实可以理解为一个单类,相同流程只会进入一次。
  • behavior上篇文章已经分析,值中有LOOKUP_INITIALIZE|LOOKUP_RESOLVER进入后异或LOOKUP_INITIALIZE|LOOKUP_RESOLVER^ LOOKUP_RESOLVER = LOOKUP_INITIALIZE,相当于清空了LOOKUP_RESOLVER
  • resolveMethod_locked参数最后一个是LOOKUP_INITIALIZE

resolveMethod_locked的源码如下:


static NEVER_INLINE IMP
resolveMethod_locked(id inst, SEL sel, Class cls, int behavior)
{
runtimeLock.assertLocked();
ASSERT(cls->isRealized());

runtimeLock.unlock();

if (! cls->isMetaClass()) {
//这里的cls是类
resolveInstanceMethod(inst, sel, cls);
}
else {
resolveClassMethod(inst, sel, cls);
if (!lookUpImpOrNilTryCache(inst, sel, cls)) {
//这里的cls是元类
resolveInstanceMethod(inst, sel, cls);
}
}

//又会去查找一次,既然这里又会去查找一次,那么肯定有什么地方会加入之前查找不存在的方法。
return lookUpImpOrForwardTryCache(inst, sel, cls, behavior);
}
  • 当快速和慢速消息查找都没有找到的时候进入了resolveMethod_locked
  • 查找的是实例方法则进行对象方法动态决议resolveInstanceMethod
  • 查找的是类方法则先进行类方法动态决议resolveClassMethod,再执行resolveInstanceMethod(这里resolveInstanceMethod调用与实例方法的resolveInstanceMethod参数不同。)。
  • 最后会调用lookUpImpOrForwardTryCache查找。

核心问题是最后要返回imp,那么先看下lookUpImpOrForwardTryCache进行的操作:

IMP lookUpImpOrForwardTryCache(id inst, SEL sel, Class cls, int behavior)
{
return _lookUpImpTryCache(inst, sel, cls, behavior);
}

只是一个简单的调用,继续排查:

ALWAYS_INLINE
static IMP _lookUpImpTryCache(id inst, SEL sel, Class cls, int behavior)
{
runtimeLock.assertUnlocked();

//是否初始化,正常情况下是已经初始化了。
if (slowpath(!cls->isInitialized())) {
// see comment in lookUpImpOrForward
//这就是慢速消息查找流程,与之前的区别是 behavior = LOOKUP_INITIALIZE,没有动态方法决议参数了。
return lookUpImpOrForward(inst, sel, cls, behavior);
}
//缓存查找
IMP imp = cache_getImp(cls, sel);
//找到直接跳转done
if (imp != NULL) goto done;
#if CONFIG_USE_PREOPT_CACHES
//动态共享缓存查找
if (fastpath(cls->cache.isConstantOptimizedCache(/* strict */true))) {
imp = cache_getImp(cls->cache.preoptFallbackClass(), sel);
}
#endif
//imp不存在继续慢速消息查找流程
if (slowpath(imp == NULL)) {
return lookUpImpOrForward(inst, sel, cls, behavior);
}

done:
//是否消息转发
if ((behavior & LOOKUP_NIL) && imp == (IMP)_objc_msgForward_impcache) {
return nil;
}
//返回imp
return imp;
}

  • isInitialized正常情况是不会进入的。
  • 先去缓存查找对应的imp,找到直接返回。
  • 没有找到会去动态共享缓存查找(如果支持)。
  • 仍然没有会进行lookUpImpOrForward也就是再进行一次慢速消息查找。

既然这个函数也是进行快速和慢速消息查找的,那么就说明resolveInstanceMethodresolveClassMethod可以在某个时机将方法加入类中。这样后面方法的调用才有意义。

二、对象方法动态决议 resolveInstanceMethod

通过源码分析发现在进行了快速与慢速消息查找后如果找不到imp,苹果仍然给了机会进行resolveInstanceMethod处理,那么核心肯定是要给类中添加imp,源码如下:

static void resolveInstanceMethod(id inst, SEL sel, Class cls)
{
runtimeLock.assertUnlocked();
ASSERT(cls->isRealized());
SEL resolve_sel = @selector(resolveInstanceMethod:);
//先进行元类查找是否实现了`resolveInstanceMethod`实例方法,也就是类的类方法。没有实现直接返回,这里不会返回,因为NSobject默认实现了。
if (!lookUpImpOrNilTryCache(cls, resolve_sel, cls->ISA(/*authenticated*/true))) {
// Resolver not implemented.
return;
}

BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend;
//系统自动发送了`resolveInstanceMethod`消息,由于消息的接受者是类,所以是+方法。
bool resolved = msg(cls, resolve_sel, sel);

// Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveInstanceMethod adds to self a.k.a. cls
//快速慢速查找
IMP imp = lookUpImpOrNilTryCache(inst, sel, cls);

if (resolved && PrintResolving) {
if (imp) {
_objc_inform("RESOLVE: method %c[%s %s] "
"dynamically resolved to %p",
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel), imp);
}
else {
// Method resolver didn't add anything?
_objc_inform("RESOLVE: +[%s resolveInstanceMethod:%s] returned YES"
", but no new implementation of %c[%s %s] was found",
cls->nameForLogging(), sel_getName(sel),
cls->isMetaClass() ? '+' : '-',
cls->nameForLogging(), sel_getName(sel));
}
}
}
  • 先进行元类resolveInstanceMethod的查找和缓存。
  • 系统自动给类发送了resolveInstanceMethod消息。既然是类调用的,那么就是+方法。
  • 接着进行了快速慢速方法查找imp,但是没有返回imp(为什么不返回?这里只是缓存,如果有的话)。
  • lookUpImpOrNilTryCachelookUpImpOrForwardTryCache唯一的区别是是否进行动态转发。这里不进行动态转发。
  • 可以看到返回的resolved只是进行了日志打印。也就是resolved返回YES/NO对功能没有影响。

那么就有个问题?
既然查找了imp为什么不进行返回操作?而resolveInstanceMethod调用结束后还查了一次?

2.1 + (BOOL)resolveInstanceMethod 调试分析

resolveInstanceMethod源码跟踪流程如下:

  • cls->ISA元类也就是HPObject元类中查找有没有实现resolveInstanceMethod-imp,最终会找到NSObject元类然后将resolveInstanceMethod-imp缓存写入HPObject元类的缓存。(NSObject默认实现了)。
  • 给类发送resolveInstanceMethod消息。
  • HPObject查找instanceMethod有没有实现,没有实现会将instanceMethod-_objc_msgForward_impcache(IMP)写入HPObject缓存。这个时候由于LOOKUP_NIL的存在返回的是`nil。
  • 如果lookUpImpOrNilTryCache没有找到imp会返回继续执行lookUpImpOrForwardTryCache继续进行缓存->消息慢速查找流程(消息慢速查找不会执行)。因为前面已经写入了对应缓存。这次会从缓存中获取到imp_objc_msgForward_impcacheimp。不会进入消息慢速查找流程,直接进行了消息转发。

这就说明resolveInstanceMethod中首先元类查找resolveInstanceMethod,目的是将resolveInstanceMethod写入缓存。然后类发送resolveInstanceMethod消息。接着lookUpImpOrNilTryCache调用是将的imp加入缓存中(无论是否找到,找不到会存入_objc_msgForward_impcache)。返回后lookUpImpOrForwardTryCache从缓存中找方法返回。

结论:resolveInstanceMethod中lookUpImpOrNilTryCache只是将方法插入缓存,返回后lookUpImpOrForwardTryCache从缓存中获取imp 这也是调用两次的原因。

2.2 + (BOOL)resolveInstanceMethod 实现

既然系统已经给了+ (BOOL)resolveInstanceMethod:(SEL)sel进行容错处理,那么就实现下:


+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"resolveInstanceMethod: %@-%@",self,NSStringFromSelector(sel));
return [super resolveInstanceMethod:sel];
}


调用后发现这个方法调用了两次:


resolveInstanceMethod: HPObject-instanceMethod
resolveInstanceMethod: HPObject-instanceMethod


  • HPObject的元类中能找到resolveInstanceMethod方法,缓存的直接是自己的imp了。
  • 仍然是没有命中进行了消息转发。

消息转发会进入class_getInstanceMethod

Method class_getInstanceMethod(Class cls, SEL sel)
{
if (!cls || !sel) return nil;

// This deliberately avoids +initialize because it historically did so.

// This implementation is a bit weird because it's the only place that
// wants a Method instead of an IMP.

#warning fixme build and search caches

// Search method lists, try method resolver, etc.
lookUpImpOrForward(nil, sel, cls, LOOKUP_RESOLVER);

#warning fixme build and search caches

return _class_getMethod(cls, sel);
}

又进行了一次lookUpImpOrForward所以这也是调用了两次的原因。但是这次不进行消息转发了,所以不会造成死循环。

总结:第一次没有命中后,再进行消息转发后又会进行一次lookUpImpOrForward消息慢速查找流程,所以resolveInstanceMethod会执行两次。

那么如果实现中添加了imp就肯定只调用一次了。
修改代码如下:


- (void)instanceMethod1 {
NSLog(@"%s",__func__);
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"HPObject resolveInstanceMethod: %@-%@",self,NSStringFromSelector(sel));
if (sel == @selector(instanceMethod)) {
IMP instanceMethod1 = class_getMethodImplementation(self, @selector(instanceMethod1));
Method method = class_getInstanceMethod(self, @selector(instanceMethod1));
const char *type = method_getTypeEncoding(method);
return class_addMethod(self, sel, instanceMethod1, type);
}
return NO;
}

按照源码理解在lookUpImpOrNilTryCache调用中只是增加到了缓存中,后面lookUpImpOrForwardTryCache会从缓存中查找,找到imp然后执行。

+ (BOOL)resolveInstanceMethod:(SEL)sel返回NO/YES根据源码来看只是打印日志相关的内容,应该是没有影响的。经过调试验证确实没有影响。

结论:

  • resolveInstanceMethod 调用中只是对方法的缓存,lookUpImpOrNilTryCache 从缓存中再次查找方法。这也是为什么会查找两次的原因。
  • resolveInstanceMethod 执行两次的原因是,在方法没有命中的时候消息转发过程中会再次进行lookUpImpOrForward(消息慢速查找),这就是执行两次的原因。
  • + (BOOL)resolveInstanceMethod:(SEL)sel 返回值不会影响功能,只是对日志打印有影响,并且默认情况下是不打印日志的。

三、类方法动态决议resolveClassMethod

在上面最开始分析的时候类方法动态决议会先调用resolveClassMethod,如果没有命中那么就会调用resolveInstanceMethod


resolveClassMethod(inst, sel, cls);
if (!lookUpImpOrNilTryCache(inst, sel, cls)) {
resolveInstanceMethod(inst, sel, cls);
}

resolveClassMethod的实现如下:


static void resolveClassMethod(id inst, SEL sel, Class cls)
{
runtimeLock.assertUnlocked();
ASSERT(cls->isRealized());
ASSERT(cls->isMetaClass());
//不会进入这里,先查找元类是否实现`resolveClassMethod`
if (!lookUpImpOrNilTryCache(inst, @selector(resolveClassMethod:), cls)) {
// Resolver not implemented.
return;
}
//类方法存在元类中,操作元类防止没有实现。
Class nonmeta;
{
mutex_locker_t lock(runtimeLock);
nonmeta = getMaybeUnrealizedNonMetaClass(cls, inst);
// +initialize path should have realized nonmeta already
if (!nonmeta->isRealized()) {
_objc_fatal("nonmeta class %s (%p) unexpectedly not realized",
nonmeta->nameForLogging(), nonmeta);
}
}
BOOL (*msg)(Class, SEL, SEL) = (typeof(msg))objc_msgSend;
//非元类调用,也就是类方法
bool resolved = msg(nonmeta, @selector(resolveClassMethod:), sel);

// Cache the result (good or bad) so the resolver doesn't fire next time.
// +resolveClassMethod adds to self->ISA() a.k.a. cls
IMP imp = lookUpImpOrNilTryCache(inst, sel, cls);

if (resolved && PrintResolving) {//......}
}

  • 第一个lookUpImpOrNilTryCache查找元类是否实现,先将resolveClassMethod插入HPObject元类的缓存中。resolveClassMethodNSObject默认实现了。
  • 操作元类,防止元类没有实现。
  • 元类中是以对象方法存在,所以在类中实现类方法就可以了。系统主动给类方法发送+ resolveClassMethod消息。这里细节的一点是通过nonmeta来发送消息。
  • lookUpImpOrNilTryCache查找目标imp,先缓存后慢速。查找到后将imp插入缓存,没有找到则将_objc_msgForward_impcache插入缓存。

实现如下:

+ (BOOL)resolveClassMethod:(SEL)sel {
NSLog(@"resolveClassMethod: %@-%@",self,NSStringFromSelector(sel));
return [super resolveClassMethod:sel];
}

调用后发现打印了8次:

resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-classMethod
resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-encodeWithOSLogCoder:options:maxLength:
resolveClassMethod: HPObject-classMethod
  • 其中encodeWithOSLogCoder与我们无关,classMethod出现两次符合预期(另外一次消息转发过程中调用)。
  • 当调用resolveClassMethod没有实现的时候,就调用resolveInstanceMethod去查找(这里的cls参数是元类,与查找实例方法不同),仍然没有找到就执行lookUpImpOrForwardTryCache
  • 最后在消息转发的时候会再执行一次方法动态决议。

修改实现:

+ (void)classMethod1 {
NSLog(@"%s",__func__);
}

+ (BOOL)resolveClassMethod:(SEL)sel {
NSLog(@"resolveClassMethod: %@-%@",self,NSStringFromSelector(sel));
if (sel == @selector(classMethod)) {
IMP classMethod1 = class_getMethodImplementation(objc_getMetaClass("HPObject"), @selector(classMethod1));
Method method = class_getClassMethod(self, @selector(classMethod1));
const char *type = method_getTypeEncoding(method);
return class_addMethod(objc_getMetaClass("HPObject"), sel, classMethod1, type);
}
return [super resolveClassMethod:sel];
}

这个时候就调用一次了。

既然resolveClassMethod找不到的时候会执行一次resolveInstanceMethod,那意味者可以在resolveInstanceMethod中对类方法进行处理。

+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"HPObject resolveInstanceMethod: %@-%@",self,NSStringFromSelector(sel));
return NO;
}

+ (BOOL)resolveClassMethod:(SEL)sel {
NSLog(@"HPObject resolveClassMethod: %@-%@",self,NSStringFromSelector(sel));
return [super resolveClassMethod:sel];
}

这个时候调试发现resolveInstanceMethod并没有执行。为什么?因为这里是HPObject元类调用resolveInstanceMethod

根据isa的走位图,NSObject同时也是元类,那么元类调用+方法就要存到元类的元类中也就是存在根元类的元类,那么就是NSObject自己,通过NSObjectresolveInstanceMethod方法就可以实现了。
添加一个NSObject的分类,实现方法:


- (void)instanceMethod1 {
NSLog(@"%s",__func__);
}

+ (void)classMethod1 {
NSLog(@"%s",__func__);
}

+ (BOOL)resolveInstanceMethod:(SEL)sel {
NSLog(@"resolveInstanceMethod: %@-%p-%@",self,self,NSStringFromSelector(sel));
if (sel == @selector(instanceMethod)) {
IMP instanceMethod1 = class_getMethodImplementation(self, @selector(instanceMethod1));
Method method = class_getInstanceMethod(self, @selector(instanceMethod1));
const char *type = method_getTypeEncoding(method);
return class_addMethod(self, sel, instanceMethod1, type);
} else if (sel == @selector(classMethod)) {
IMP classMethod1 = class_getMethodImplementation(objc_getMetaClass("HPObject"), @selector(classMethod1));
Method method = class_getInstanceMethod(objc_getMetaClass("HPObject"), @selector(classMethod1));
const char *type = method_getTypeEncoding(method);
return class_addMethod(objc_getMetaClass("HPObject"), sel, classMethod1, type);
}
return NO;
}

分别调用instanceMethodclassMethod输出如下:

HPObject:0x1000082c8, HPMetaObject:0x1000082a0, NSObject:0x100358140, NSMetaObject:0x1003580f0

resolveInstanceMethod: HPObject-0x1000082c8-instanceMethod //类
-[NSObject(Additions) instanceMethod1]
resolveInstanceMethod: HPObject-0x1000082a0-classMethod //元类
HPObjcTest[59242:11857560] +[NSObject(Additions) classMethod1]

这样就在NSObjectresolveInstanceMethod中即处理了类方法也处理了实例方法。两次调用参数不同,一次是类调用,一次是元类调用。

⚠️如果两个都实现在HPObject类中,则都是类调用。

总结

  • resolveClassMethod 调用中只是对方法的缓存,lookUpImpOrNilTryCache会从缓存中再次查找方法,这也是为什么会查找两次的原因。
  • resolveClassMethod 执行两次的原因是在方法没有命中的时候消息转发过程中会再次进行lookUpImpOrForward(消息慢速查找),再次走这个流程。这就是执行两次的原因。LOOKUP_NIL有值,所以不会再次消息转发,不会造成死循环。)
  • resolveClassMethod 没有命中的时候会先调用resolveInstanceMethod(这里的cls是元类),再次调用时因为NSObject是元类的父类。
  • 这里resolveInstanceMethod由于是元类调用,所以只能实现在NSObject的分类中。(根元类的元类是自己,它的父类是NSObject
  • + (BOOL)resolveClassMethod:(SEL)sel 返回值不会影响功能,只是对日志打印有影响。

三、aop & oop

那么动态方法决议的意义在哪里呢?
这是苹果在sel查找imp找不到的时候给的一次解决错误的机会。有什么意义呢?在NSObject的分类中,所有找不到的OC方法都能在resolveInstanceMethod中监听到。
那么在自己的工程中可以根据类名前缀、模块以及事物进行区分prefix_ module_traffic。当发现有问题的时候可以进行容错处理并且上报错误信息。 比如HP_Setting_didClickLogin出现问题的时候进行上报,当超过阈值时进行报警。

这种方式就是aop切面编程。我们比较习惯的方式是oop

oop
oop分工非常明确,耦合度小,冗余代码。一般情况下会提取公共的类,但是遵循后会对它有强依赖,强耦合。
这些其实不是我们关心的,我们更关心业务的内容,所以公共类尽量少侵入,最好无侵入。通过动态方式注入代码,对原始方法没有影响。这就相当于整个切面切入了,要切入的方法和类就是切点。aopoop的延伸。

aop
aop的缺点在上面的例子中是if-else过多冗余。正如上面看到的那样,方法会调用很多次浪费了相应的性能。如果命中还好,没有命中会走多次,会有性能消耗。它是消息转发机制的前一个阶段。意味着如果在这里做了容错处理,后面的流程就被切掉了。苹果写转发流程就没有意义了。

如果其它模块也做了相应处理,重复了这块不一定会执行到。所以在后面的流程做aop更合理。



作者:HotPotCat
链接:https://www.jianshu.com/p/7daa33b95cd3
收起阅读 »

GCD底层分析 - 队列、同步异步函数

一、GCD 简介1.1 GCDGCD(Grand Central Dispatch)本质是 将任务添加到队列,并且指定执行任务的函数。GCD是纯C语言实现,提供了非常强大的函数。GCD的优势:是苹果公司为多核的并行运算提出的解决方案。会自动利用更多的...
继续阅读 »

一、GCD 简介

1.1 GCD

GCDGrand Central Dispatch)本质是 将任务添加到队列,并且指定执行任务的函数

GCD是纯C语言实现,提供了非常强大的函数。GCD的优势:

  • 是苹果公司为多核的并行运算提出的解决方案。
  • 会自动利用更多的CPU内核(比如双核、四核)。
  • 会自动管理线程的生命周期(创建线程、调度任务、销毁线程)。
  • 程序员只需要告诉 GCD 想要执行什么任务,不需要编写任何线程管理代码。

最简单的一个例子:

dispatch_block_t block = ^{
NSLog(@"Hello GCD");
};
//串行队列
dispatch_queue_t quene = dispatch_queue_create("com.HotpotCat.zai", NULL);
//函数
dispatch_async(quene, block);
  • 任务使用block封装,这个block块就是任务。任务的block没有参数也没有返回值
  • quene是创建的串行队列。
  • 通过函数将任务和队列关联在一起。

1.2 GCD 的作用



二、函数与队列

2.1 同步与异步

  • dispatch_async异步执行任务的函数。
    • 不用等待当前语句执行完毕,就可以执行下一条语句。
    • 会开启线程执行block的任务。
    • 异步是多线程的代名词。
  • dispatch_sync同步函数。
    • 必须等待当前语句执行完毕,才会执行下一条语句。
    • 不会开启线程。
    • 在当前执行block任务。
  • block块是在函数内部执行的。

有如下案例:

- (void)test {
CFAbsoluteTime time = CFAbsoluteTimeGetCurrent();
dispatch_queue_t queue = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_SERIAL);

dispatch_async(queue, ^{
NSLog(@"1: %f",CFAbsoluteTimeGetCurrent() - time);
method();
});

dispatch_sync(queue, ^{
NSLog(@"2: %f",CFAbsoluteTimeGetCurrent() - time);
method();
});

method();
NSLog(@"3: %f",CFAbsoluteTimeGetCurrent() - time);
}

void method() {
sleep(3);
}

输出:

1: 0.000055
2: 3.000264
3: 9.001459

说明同步异步是一个耗时的操作。

2.2 串行队列与并发队列




  • 队列:是一种数据结构,支持FIFO原则。
  • 串行队列:一次只能进一个任务,任务之间需要排队,DFQ_WIDTH = 1。在上图中任务一比任务二先执行,队列中的任务按顺序执行。
  • 并发队列:一次能调度多个任务(调度多个并不是执行多个,队列不具备执行任务能力,线程才能执行任务),任务一先调度不一定比任务二先执行,得看线程池的调度情况(先调度不一定先执行)。

⚠️ 队列与线程没有任何关系,队列存储任务,线程执行任务

2.2.1 案例1

有如下代码:

dispatch_queue_t queue = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_CONCURRENT);
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
NSLog(@"4");
});
NSLog(@"5");

输出:1 5 2 3 4
分析:queue是并发队列,dispatch_async是异步函数。所以先执行1 5, 在dispatch_asyncblock内部先执行2,由于dispatch_sync同步函数导致3执行完才能执行4。 所以输出1 5 2 3 4

2.2.2 案例2

将上面的例子中DISPATCH_QUEUE_CONCURRENT改为NULL(DISPATCH_QUEUE_SERIAL):





这个时候在执行到dispatch_sync的时候发生了死锁。
对于同步函数会进行护犊子,堵塞的是block之后的代码。queue中的任务如下(这里为了简单没有写外层异步函数的任务块):



由于queue是串行队列并且支持FIFO,在queue中块任务为同步函数需要保证任务3执行,但是queue是串行队列,任务3的执行依赖于任务4,而任务4以来块任务所以发生了循环等待,造成死锁。如果改为并发队列(34可以一起执行)或者任务3为异步函数调用则就不会发生死锁了。

2.2.3 案例3

继续修改代码,将任务4删除,代码如下:

- (void)test{
dispatch_queue_t queue = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_SERIAL);
NSLog(@"1");
dispatch_async(queue, ^{
NSLog(@"2");
dispatch_sync(queue, ^{
NSLog(@"3");
});
});
NSLog(@"5");
}

这个时候仍然发生了死锁。对于queue而言有两个任务块以及一个任务2





任务块2阻塞了任务块1的执行完毕,任务块2的执行依赖于任务3的执行,任务3的执行完毕以来于任务块1。这样就造成了死锁。

2.2.4 案例4

- (void)test {
dispatch_queue_t queue = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue, ^{
NSLog(@"1");
});

dispatch_async(queue, ^{
NSLog(@"2");
});

dispatch_sync(queue, ^{ NSLog(@"3"); });

NSLog(@"0");

dispatch_async(queue, ^{
NSLog(@"7");
});
dispatch_async(queue, ^{
NSLog(@"8");
});
dispatch_async(queue, ^{
NSLog(@"9");
});

}

输出选项:

A: 1230789
B: 1237890
C: 3120798
D: 2137890

queue是并发队列,任务3是同步函数执行的。所以任务3后面的任务会被阻塞。那么任务0肯定在任务3之后执行,任务7、8、9肯定在任务0之后执行。所以就有1、 2、 3 — 0 — 7、 8、 9。而由于本身是并发队列,所以1、2、3之间是无序的,7、8、9之间也是无序的。所以A、C符合。

dispatch_async中增加耗时操作,修改如下:

- (void)test {
dispatch_queue_t queue = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_CONCURRENT);

dispatch_async(queue, ^{
sleep(8);
NSLog(@"1");
});

dispatch_async(queue, ^{
sleep(7);
NSLog(@"2");
});

dispatch_sync(queue, ^{
sleep(1);
NSLog(@"3");
});

NSLog(@"0");

dispatch_async(queue, ^{
sleep(3);
NSLog(@"7");
});
dispatch_async(queue, ^{
sleep(2);
NSLog(@"8");
});
dispatch_async(queue, ^{
sleep(1);
NSLog(@"9");
});
}

当任务中有耗时操作时1、2就不一定在0之前执行了,核心点在于dispatch_sync只能保证任务3先执行不能保证1、2先执行。而0仍然在7、8、9之前。
所以只能保证:30之前执行。07、8、9之前执行(1、2、7、8、9无序,31、2之间无序)。此时上面的代码输出3098721

修改queue为串行队列DISPATCH_QUEUE_SERIAL后,1、2、3以及7、8、9就是按顺序执行了1230789就选A了。

小结:

  • 同步函数串行队列:
    • 不会开启线程,在当前线程执行任务。
    • 任务串行执行,一个接着一个。
    • 会产生阻塞。
  • 同步函数并发队列:
    • 不会开启线程,在当前线程执行任务。
    • 任务一个接着一个。
  • 异步函数串行队列:
    • 开启线程,一条新线程。
    • 任务一个接着一个。
  • 异步函数并发队列:
    • 开启线程,在当前线程执行任务。
    • 任务异步执行,没有顺序,与CPU调用有关。

三、主队列与全局并发队列

通过GCD创建队列,一般有以下4种方式:

- (void)test {
//串行队列
dispatch_queue_t serial = dispatch_queue_create("com.HotpotCat.cat", DISPATCH_QUEUE_SERIAL);
//并发队列
dispatch_queue_t concurrent = dispatch_queue_create("com.HotpotCat.zai", DISPATCH_QUEUE_CONCURRENT);
//主队列
dispatch_queue_t mainQueue = dispatch_get_main_queue();
//全局队列
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
NSLog(@"serial:%@\nconcurrent:%@\nmainQueue:%@\nglobalQueue:%@",serial,concurrent,mainQueue,globalQueue);
}

3.1 主队列

dispatch_get_main_queue声明如下:




3.1.1 dispatch_get_main_queue 源码分析

那么dispatch_get_main_queue具体是在什么时机创建的呢?
main_queueblock中打断点bt查看堆栈定位到调用是在libdispatch中:




dispatch_get_main_queue
dispatch_get_main_queue定义如下:

dispatch_queue_main_t
dispatch_get_main_queue(void)
{
//dispatch_queue_main_t 是类型,真正的对象是_dispatch_main_q
return DISPATCH_GLOBAL_OBJECT(dispatch_queue_main_t, _dispatch_main_q);
}

dispatch_get_main_queue返回了DISPATCH_GLOBAL_OBJECT,参数是dispatch_queue_main_t以及_dispatch_main_q

DISPATCH_GLOBAL_OBJECT的宏定义如下:

#define DISPATCH_GLOBAL_OBJECT(type, object) ((OS_OBJECT_BRIDGE type)&(object))

可以看到dispatch_queue_main_t是一个类型,真正的对象是object也就是_dispatch_main_q

_dispatch_main_q
_dispatch_main_q的函数搜索不到,此时通过赋值可以直接定位到:




当然也可以通过label com.apple.main-thread搜索定位到。

struct dispatch_queue_static_s _dispatch_main_q = {
DISPATCH_GLOBAL_OBJECT_HEADER(queue_main),
#if !DISPATCH_USE_RESOLVERS
.do_targetq = _dispatch_get_default_queue(true),
#endif
.dq_state = DISPATCH_QUEUE_STATE_INIT_VALUE(1) |
DISPATCH_QUEUE_ROLE_BASE_ANON,
.dq_label = "com.apple.main-thread",
//DQF_WIDTH(1) 串行队列
.dq_atomic_flags = DQF_THREAD_BOUND | DQF_WIDTH(1),
.dq_serialnum = 1,
};

  • DISPATCH_GLOBAL_OBJECT_HEADER(queue_main)传递的参数是queue_main
  • DQF_WIDTH区分串行并行队列。并不是通过serialnum
  • 最终返回的类型是dispatch_queue_main_t。在使用过程中都是使用dispatch_queue_t接收的。

dispatch_queue_static_s定义如下:

typedef struct dispatch_lane_s {
DISPATCH_LANE_CLASS_HEADER(lane);
/* 32bit hole on LP64 */
} DISPATCH_ATOMIC64_ALIGN *dispatch_lane_t;

// Cache aligned type for static queues (main queue, manager)
struct dispatch_queue_static_s {
struct dispatch_lane_s _as_dl[0]; \
DISPATCH_LANE_CLASS_HEADER(lane);
} DISPATCH_CACHELINE_ALIGN;

内部实际上是dispatch_lane_s

3.2 全局队列

dispatch_get_global_queue的实现如下:

dispatch_queue_global_t
dispatch_get_global_queue(intptr_t priority, uintptr_t flags)
{
dispatch_assert(countof(_dispatch_root_queues) ==
DISPATCH_ROOT_QUEUE_COUNT);

//过量使用直接返回0
if (flags & ~(unsigned long)DISPATCH_QUEUE_OVERCOMMIT) {
return DISPATCH_BAD_INPUT;
}
//根据优先级返回qos
dispatch_qos_t qos = _dispatch_qos_from_queue_priority(priority);
#if !HAVE_PTHREAD_WORKQUEUE_QOS
if (qos == QOS_CLASS_MAINTENANCE) {
qos = DISPATCH_QOS_BACKGROUND;
} else if (qos == QOS_CLASS_USER_INTERACTIVE) {
qos = DISPATCH_QOS_USER_INITIATED;
}
#endif
if (qos == DISPATCH_QOS_UNSPECIFIED) {
return DISPATCH_BAD_INPUT;
}
//调用 _dispatch_get_root_queue
return _dispatch_get_root_queue(qos, flags & DISPATCH_QUEUE_OVERCOMMIT);
}
  • 返回dispatch_queue_global_t
  • 通过_dispatch_get_root_queue获取队列。

_dispatch_get_root_queue实现:

static inline dispatch_queue_global_t
_dispatch_get_root_queue(dispatch_qos_t qos, bool overcommit)
{
if (unlikely(qos < DISPATCH_QOS_MIN || qos > DISPATCH_QOS_MAX)) {
DISPATCH_CLIENT_CRASH(qos, "Corrupted priority");
}
return &_dispatch_root_queues[2 * (qos - 1) + overcommit];
}
  • 先对优先级进行验证。
  • _dispatch_root_queues集合中取数据。

_dispatch_root_queues实现:

//静态变量集合,随时调用随时取。
struct dispatch_queue_global_s _dispatch_root_queues[] = {
#define _DISPATCH_ROOT_QUEUE_IDX(n, flags) \
((flags & DISPATCH_PRIORITY_FLAG_OVERCOMMIT) ? \
DISPATCH_ROOT_QUEUE_IDX_##n##_QOS_OVERCOMMIT : \
DISPATCH_ROOT_QUEUE_IDX_##n##_QOS)

#define _DISPATCH_ROOT_QUEUE_ENTRY(n, flags, ...) \
[_DISPATCH_ROOT_QUEUE_IDX(n, flags)] = { \
DISPATCH_GLOBAL_OBJECT_HEADER(queue_global), \
.dq_state = DISPATCH_ROOT_QUEUE_STATE_INIT_VALUE, \
.do_ctxt = _dispatch_root_queue_ctxt(_DISPATCH_ROOT_QUEUE_IDX(n, flags)), \
.dq_atomic_flags = DQF_WIDTH(DISPATCH_QUEUE_WIDTH_POOL), \
.dq_priority = flags | ((flags & DISPATCH_PRIORITY_FLAG_FALLBACK) ? \
_dispatch_priority_make_fallback(DISPATCH_QOS_##n) : \
_dispatch_priority_make(DISPATCH_QOS_##n, 0)), \
__VA_ARGS__ \
}

_DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, 0,
.dq_label = "com.apple.root.maintenance-qos",
.dq_serialnum = 4,
),
_DISPATCH_ROOT_QUEUE_ENTRY(MAINTENANCE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.maintenance-qos.overcommit",
.dq_serialnum = 5,
),
_DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, 0,
.dq_label = "com.apple.root.background-qos",
.dq_serialnum = 6,
),
_DISPATCH_ROOT_QUEUE_ENTRY(BACKGROUND, DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.background-qos.overcommit",
.dq_serialnum = 7,
),
_DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, 0,
.dq_label = "com.apple.root.utility-qos",
.dq_serialnum = 8,
),
_DISPATCH_ROOT_QUEUE_ENTRY(UTILITY, DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.utility-qos.overcommit",
.dq_serialnum = 9,
),
_DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT, DISPATCH_PRIORITY_FLAG_FALLBACK,
.dq_label = "com.apple.root.default-qos",
.dq_serialnum = 10,
),
_DISPATCH_ROOT_QUEUE_ENTRY(DEFAULT,
DISPATCH_PRIORITY_FLAG_FALLBACK | DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.default-qos.overcommit",
.dq_serialnum = 11,
),
_DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, 0,
.dq_label = "com.apple.root.user-initiated-qos",
.dq_serialnum = 12,
),
_DISPATCH_ROOT_QUEUE_ENTRY(USER_INITIATED, DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.user-initiated-qos.overcommit",
.dq_serialnum = 13,
),
_DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, 0,
.dq_label = "com.apple.root.user-interactive-qos",
.dq_serialnum = 14,
),
_DISPATCH_ROOT_QUEUE_ENTRY(USER_INTERACTIVE, DISPATCH_PRIORITY_FLAG_OVERCOMMIT,
.dq_label = "com.apple.root.user-interactive-qos.overcommit",
.dq_serialnum = 15,
),
};
  • 根据下标取对应的队列。
  • _dispatch_root_queues是一个静态变量集合,随时调用随时获取。
  • 在使用过程中都是使用dispatch_queue_t接收。
  • DISPATCH_GLOBAL_OBJECT_HEADER(queue_global)传递的参数是queue_global

小结:
主队列(dispatch_get_main_queue()

  • 专⻔用来在主线程上调度任务的串行队列。
  • 不会开启线程。
  • 如果当前主线程正在有任务执行,那么无论主队列中当前被添加了什么任务,都不会被调度。

全局并发队列

  • 为了方便使用,提供了全局队列 dispatch_get_global_queue(0, 0)
  • 全局队列是一个并发队列。
  • 在使用多线程开发时,如果对队列没有特殊需求,在执行异步任务时,可以直接使用全局队列。

四、dispatch_queue_create

普通的串行队列以及并发队列是通过dispatch_queue_create创建的,它的实现如下

dispatch_queue_t
dispatch_queue_create(const char *label, dispatch_queue_attr_t attr)
{
return _dispatch_lane_create_with_target(label, attr,
DISPATCH_TARGET_QUEUE_DEFAULT, true);
}

返回dispatch_queue_t类型,内部直接调用_dispatch_lane_create_with_target

4.1 _dispatch_lane_create_with_target


  • _dispatch_queue_attr_to_info根据dqa(串行/并行)创建dqai
  • 返回的dqai进行优先级相关的处理,进行准备工作。
  • 初始化队列
    • 根据dqai_concurrent串行并行获取vtable(class name)。参数是queue_concurrent或者queue_serial
    • _dispatch_object_alloc开辟空间。
    • _dispatch_queue_init初始化,根据dqai_concurrent传递DISPATCH_QUEUE_WIDTH_MAX 或者 1区分是串行还是并行。
    • label赋值。
    • 优先级设置。
  • _dispatch_trace_queue_create追踪返回_dq,重点在dq

这里创建queue并不是通过queue相关对象进行创建和接收。

4.2 _dispatch_queue_attr_to_info


  • 串行队列(dqa 为空)直接返回。
  • 并行队列进行一系列配置。

4.3 _dispatch_object_alloc

_dispatch_object_alloc传递的参数是vtable以及sizeof(struct dispatch_lane_s),那么意味着实际上也是dispatch_lane_s类型。


void *
_dispatch_object_alloc(const void *vtable, size_t size)
{
#if OS_OBJECT_HAVE_OBJC1
const struct dispatch_object_vtable_s *_vtable = vtable;
dispatch_object_t dou;
dou._os_obj = _os_object_alloc_realized(_vtable->_os_obj_objc_isa, size);
dou._do->do_vtable = vtable;
return dou._do;
#else
return _os_object_alloc_realized(vtable, size);
#endif
}
  • 调用_os_object_alloc_realized申请开辟内存。

4.3.1 _os_object_alloc_realized

inline _os_object_t
_os_object_alloc_realized(const void *cls, size_t size)
{
_os_object_t obj;
dispatch_assert(size >= sizeof(struct _os_object_s));
while (unlikely(!(obj = calloc(1u, size)))) {
_dispatch_temporary_resource_shortage();
}
obj->os_obj_isa = cls;
return obj;
}

内部直接调用calloc开辟空间。

4.4 _dispatch_queue_init

根据是否并行队列传递DISPATCH_QUEUE_WIDTH_MAX1

#define DISPATCH_QUEUE_WIDTH_FULL           0x1000ull
#define DISPATCH_QUEUE_WIDTH_POOL (DISPATCH_QUEUE_WIDTH_FULL - 1)
#define DISPATCH_QUEUE_WIDTH_MAX (DISPATCH_QUEUE_WIDTH_FULL - 2)


  • 根据width配置状态。
  • 根据width配置队列串行/并行以及dq_serialnum标识。dq_serialnum通过_dispatch_queue_serial_numbers赋值。
unsigned long volatile _dispatch_queue_serial_numbers =
DISPATCH_QUEUE_SERIAL_NUMBER_INIT;
// skip zero
// 1 - main_q
// 2 - mgr_q
// 3 - mgr_root_q
// 4,5,6,7,8,9,10,11,12,13,14,15 - global queues
// 17 - workloop_fallback_q
// we use 'xadd' on Intel, so the initial value == next assigned
#define DISPATCH_QUEUE_SERIAL_NUMBER_INIT 17
extern unsigned long volatile _dispatch_queue_serial_numbers;
  • 0跳过。
  • 1主队列。
  • 2管理队列。
  • 3管理队列的目标队列。
  • 4~15全局队列。根据qos优先级指定不同队列。
  • 17自动创建相关返回队列。

os_atomic_inc_orig传递的参数是_dispatch_queue_serial_numbersp)以及relaxedm):


#define os_atomic_inc_orig(p, m) \
os_atomic_add_orig((p), 1, m)

#define os_atomic_add_orig(p, v, m) \
_os_atomic_c11_op_orig((p), (v), m, add, +)

//##是连接符号,编译后会被去掉
#define _os_atomic_c11_op_orig(p, v, m, o, op) \
atomic_fetch_##o##_explicit(_os_atomic_c11_atomic(p), v, \
memory_order_##m)
  • 最终调用的是atomic_fetch_add_explicit(_os_atomic_c11_atomic(17), 1, memory_order_relaxed)。原子替换(p + 1 -> p), 并返回p之前的值。也就相当于是i++这是原子操作。
  • 这里多层宏定义处理是为了兼容不同的c/c++版本。

  • C atomic_fetch_add_explicit(volatile A * obj,M arg,memory_order order);

    enum memory_order {
    memory_order_relaxed, //不对执行顺序做保证,只保证此操作是原子的
    memory_order_consume, // 本线程中,所有后续的有关本原子类型的操作,必须>在本条原子操作完成之后执行
    memory_order_acquire, //本线程中,所有后续的读操作必须在本条原子操作完>成后执行
    memory_order_release, //本线程中,所有之前的写操作完成后才能执行本条原子操作
    memory_order_acq_rel, //同时包含 memory_order_acquire 和 memory_order_release
    memory_order_seq_cst //全部存取都按顺序执行
    };

    4.5 _dispatch_trace_queue_create


    #define _dispatch_trace_queue_create _dispatch_introspection_queue_create

    dispatch_queue_class_t
    _dispatch_introspection_queue_create(dispatch_queue_t dq)
    {
    dispatch_queue_introspection_context_t dqic;
    size_t sz = sizeof(struct dispatch_queue_introspection_context_s);

    if (!_dispatch_introspection.debug_queue_inversions) {
    sz = offsetof(struct dispatch_queue_introspection_context_s,
    __dqic_no_queue_inversion);
    }
    dqic = _dispatch_calloc(1, sz);
    dqic->dqic_queue._dq = dq;
    if (_dispatch_introspection.debug_queue_inversions) {
    LIST_INIT(&dqic->dqic_order_top_head);
    LIST_INIT(&dqic->dqic_order_bottom_head);
    }
    dq->do_introspection_ctxt = dqic;

    _dispatch_unfair_lock_lock(&_dispatch_introspection.queues_lock);
    LIST_INSERT_HEAD(&_dispatch_introspection.queues, dqic, dqic_list);
    _dispatch_unfair_lock_unlock(&_dispatch_introspection.queues_lock);

    DISPATCH_INTROSPECTION_INTERPOSABLE_HOOK_CALLOUT(queue_create, dq);
    if (DISPATCH_INTROSPECTION_HOOK_ENABLED(queue_create)) {
    _dispatch_introspection_queue_create_hook(dq);
    }
    return upcast(dq)._dqu;
    }

    五、dispatch_queue_t

    由于所有的队列都是通过dispatch_queue_t来接收的,直接研究dispatch_queue_t是一个不错的入口。
    dispatch_queue_t的点击会跳转到:


    DISPATCH_DECL(dispatch_queue);

    5.1 DISPATCH_DECL 源码分析

    DISPATCH_DECL宏定义:

    #define DISPATCH_DECL(name) OS_OBJECT_DECL_SUBCLASS(name, dispatch_object)
    真实类型是OS_OBJECT_DECL_SUBCLASS,在源码中它的定义有两个,一个是针对objc的,定义如下:
    #define OS_OBJECT_DECL_SUBCLASS(name, super) \
    OS_OBJECT_DECL_IMPL(name, NSObject, <OS_OBJECT_CLASS(super)>)

    OS_OBJECT_DECL_IMPL定义如下:

    #define OS_OBJECT_DECL_IMPL(name, adhere, ...) \
    OS_OBJECT_DECL_PROTOCOL(name, __VA_ARGS__) \
    typedef adhere<OS_OBJECT_CLASS(name)> \
    * OS_OBJC_INDEPENDENT_CLASS name##_t

    OS_OBJECT_DECL_PROTOCOL定义如下:

    #define OS_OBJECT_DECL_PROTOCOL(name, ...) \
    @protocol OS_OBJECT_CLASS(name) __VA_ARGS__ \
    @end
    • 也就是定义了一个@protocol

    OS_OBJC_INDEPENDENT_CLASS定义如下:

    #if __has_attribute(objc_independent_class)
    #define OS_OBJC_INDEPENDENT_CLASS __attribute__((objc_independent_class))
    #endif // __has_attribute(objc_independent_class)
    #ifndef OS_OBJC_INDEPENDENT_CLASS
    #define OS_OBJC_INDEPENDENT_CLASS
    #endif

    为了方便分析,这里假设它走的是OS_OBJC_INDEPENDENT_CLASS为空。

    OS_OBJECT_CLASS定义如下:

    #define OS_OBJECT_CLASS(name) OS_##name
    • 本质上是OS_拼接。

    在整个宏定义中参数 name: dispatch_queue super: dispatch_object,整个宏定义替换完成后如下:

    @protocol OS_dispatch_queue <OS_dispatch_object>
    @end
    typedef NSObject<OS_dispatch_queue> *dispatch_queue_t

    当然在源码中搜索#define DISPATCH_DECL可以搜到多个,其中有一个定义如下:

    #define DISPATCH_DECL(name) \
    typedef struct name##_s : public dispatch_object_s {} *name##_t

    替换后如下:

    typedef struct dispatch_queue_s : public dispatch_object_s {} *dispatch_queue_t
    • dispatch_queue_t是一个结构体来自于dispatch_queue_s
    • dispatch_queue_s继承自dispatch_object_s
    • dispatch_queue_t -> dispatch_queue_s -> dispatch_object_s类似于class -> objc_class -> objc_object
    • 本质上dispatch_queue_tdispatch_queue_s结构体类型。

    5.2 dispatch_queue_s 分析

    要研究dispatch_queue_t那么就要研究它的类型dispatch_queue_s
    dispatch_queue_s定义如下:


    struct dispatch_queue_s {
    DISPATCH_QUEUE_CLASS_HEADER(queue, void *__dq_opaque1);
    /* 32bit hole on LP64 */
    } DISPATCH_ATOMIC64_ALIGN;


    内部继承于_DISPATCH_QUEUE_CLASS_HEADER


    _DISPATCH_QUEUE_CLASS_HEADER内部又继承自DISPATCH_OBJECT_HEADER


    • 这里就对接到了dispatch_object_s类型。

    DISPATCH_OBJECT_HEADER内部又使用_DISPATCH_OBJECT_HEADER

    #define _DISPATCH_OBJECT_HEADER(x) \
    struct _os_object_s _as_os_obj[0]; \
    OS_OBJECT_STRUCT_HEADER(dispatch_##x); \
    struct dispatch_##x##_s *volatile do_next; \
    struct dispatch_queue_s *do_targetq; \
    void *do_ctxt; \
    union { \
    dispatch_function_t DISPATCH_FUNCTION_POINTER do_finalizer; \
    void *do_introspection_ctxt; \
    }

    也就是最终使用的是_os_object_s类型。

    OS_OBJECT_STRUCT_HEADER类型:

    #define OS_OBJECT_STRUCT_HEADER(x) \
    _OS_OBJECT_HEADER(\
    const struct x##_vtable_s *__ptrauth_objc_isa_pointer do_vtable, \
    do_ref_cnt, \
    do_xref_cnt)
    #endif

    _OS_OBJECT_HEADER3个成员变量:

    #define _OS_OBJECT_HEADER(isa, ref_cnt, xref_cnt) \
    isa; /* must be pointer-sized and use __ptrauth_objc_isa_pointer */ \
    int volatile ref_cnt; \
    int volatile xref_cnt

    至此整个继承链为:dispatch_queue_t -> dispatch_queue_s -> dispatch_object_s -> _os_object_s

    5.3 dispatch_object_s 分析



    • dispatch_object_t是一个联合体,那么意味着它可以是里面数据类型的任意一种。
    • 其中有dispatch_object_s类型,那么意味者它底层实际上是dispatch_object_t类型。
    • _os_object_s也与上面的分析相对应。

    总结: 整个继承链为:dispatch_queue_t -> dispatch_queue_s -> dispatch_object_s -> _os_object_s -> dispatch_object_t



    作者:HotPotCat
    链接:https://www.jianshu.com/p/1b2202ecb964
    收起阅读 »

    iOS LLDB(Low Lever Debug)

    一、概述LLDB(Low Lever Debug这里的low指轻量级)默认内置于Xcode中的动态调试工具。标准的 LLDB 提供了一组广泛的命令,旨在与老版本的 GDB 命令兼容。 除了使用标准配置外,还可以很容易地自...
    继续阅读 »

    一、概述

    LLDB(Low Lever Debug这里的low指轻量级)默认内置于Xcode中的动态调试工具。标准的 LLDB 提供了一组广泛的命令,旨在与老版本的 GDB 命令兼容。 除了使用标准配置外,还可以很容易地自定义 LLDB 以满足实际需要。

    二、LLDB语法

    <command> [<subcommand> [<subcommand>...]] <action> [-options [option-value]] [argument [argument...]]
    • command:命令
    • subcommand:子命令
    • action:执行命令的操作
    • options:命令选项
    • arguement:参数
    • []:表示可选

    例子:

    //command     action    option    arguement
    breakpoint set -n test1
    唯一匹配原则:根据n个字母已经能唯一匹配到某个命令,则只写n个字母等效于完整的命令(大小写敏感)。也就是说只要能识别出来命令唯一就可以:

    br s -n test1

    help

    直接在LLDB中输入help可以查看所有的LLDB命令。
    查看某一个命令help /help :

    help breakpoint
    help breakpoint set

    apropos

    apropos可以用来搜索命令相关信息。

    //将所有breakpoint命令搜索出来
    apropos breakpoint

    三、lldb常用命令

    3.1 lldb断点设置

    3.1.1 breakpoint

    breakpoint set

    • set 是子命令
    • -n 是选项--name 的缩写。

    根据方法名设置断点
    breakpoint set -n test1,相当于对符号test1下断点,所有的test1都会被断住:

    Breakpoint 4: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010be80d00

    这和在Xcode中设置符号断点是一样的:



    区别是前者重新启动后就失效了。

    设置组断点
    breakpoint set -n "[ViewController click1:]" -n "[ViewController click2:]" -n "[ViewController click3:]"相当于下了一组断点:

    (lldb) breakpoint set -n "[ViewController click1:]" -n "[ViewController click2:]" -n "[ViewController click3:]"
    Breakpoint 6: 3 locations.
    (lldb) breakpoint list 6
    6: names = {'[ViewController click1:]', '[ViewController click1:]', '[ViewController click1:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click3:]', '[ViewController click3:]', '[ViewController click3:]'}, locations = 3, resolved = 3, hit count = 0
    6.1: where = LLDBTest`-[ViewController click1:] at ViewController.m:31, address = 0x000000010be80e00, resolved, hit count = 0
    6.2: where = LLDBTest`
    -[ViewController click2:] at ViewController.m:35, address = 0x000000010be80e40, resolved, hit count = 0
    6.3: where = LLDBTest`-[ViewController click3:] at ViewController.m:40, address = 0x000000010be80e80, resolved, hit count = 0

    可以同时启用和禁用组断点。

    使用-f指定文件

    (lldb) br set -f ViewController.m -n click1:
    Breakpoint 12: where = LLDBTest`-[ViewController click1:] at ViewController.m:31, address = 0x000000010be80e00

    使用-l指定某一行设置断点

    (lldb) br set -f ViewController.m -l 40
    Breakpoint 13: where = LLDBTest`-[ViewController click3:] at ViewController.m:40, address = 0x000000010be80e80
    使用-c设置条件断点
    只要计算的结果是个bool型或整型数值就可以。test2:方法接收一个布尔值参数,则当传入的值为YES时才断住:

    (lldb) br set -n test2: -c enable==YES
    Breakpoint 1: where = LLDBTest`-[ViewController test2:] at ViewController.m:45, address = 0x0000000100dc1e80

    使用-F设置函数全名

    (lldb) br set -F test1
    Breakpoint 1: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010762ecb0

    使用-a设置地址断点

    (lldb)  br set -a 0x000000010762ecb0
    Breakpoint 2: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010762ecb0

    使用--selector设置断点

    (lldb) br set -n touchesBegan:withEvent:
    Breakpoint 2: 97 locations.
    (lldb) br set --selector touchesBegan:withEvent:
    Breakpoint 3: 97 locations.

    --selector在这里和-n等价都是全部匹配。不过-n是针对符号,--selector针对OC的方法。

    使用-r模糊匹配

    (lldb) br set -f ViewController.m -r test
    Breakpoint 4: 2 locations.
    (lldb) br list
    Current breakpoints:
    4: regex = 'test', locations = 2, resolved = 2, hit count = 0
    4.1: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010762ecb0, resolved, hit count = 0
    4.2: where = LLDBTest`-[ViewController test2:] at ViewController.m:45, address = 0x000000010762ee80, resolved, hit count = 0

    使用-i设置忽略次数

    (lldb) br set -f ViewController.m -r test -i 3
    Breakpoint 1: 2 locations.

    这里的次数是这组所有断点加起来的次数。

    breakpoint list

    查看断点列表:

    (lldb) br l
    Current breakpoints:
    7: names = {'[ViewController click1:]', '[ViewController click1:]', '[ViewController click1:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click3:]', '[ViewController click3:]', '[ViewController click3:]'}, locations = 3, resolved = 3, hit count = 0
    7.1: where = LLDBTest`-[ViewController click1:] at ViewController.m:31, address = 0x000000010be80e00, resolved, hit count = 0
    7.2: where = LLDBTest`-[ViewController click2:] at ViewController.m:35, address = 0x000000010be80e40, resolved, hit count = 0
    7.3: where = LLDBTest`-[ViewController click3:] at ViewController.m:40, address = 0x000000010be80e80, resolved, hit count = 0

    查看某一个/某一组断点:

    (lldb) br l
    Current breakpoints:
    7: names = {'[ViewController click1:]', '[ViewController click1:]', '[ViewController click1:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click3:]', '[ViewController click3:]', '[ViewController click3:]'}, locations = 3, resolved = 3, hit count = 0
    7.1: where = LLDBTest`-[ViewController click1:] at ViewController.m:31, address = 0x000000010be80e00, resolved, hit count = 0
    7.2: where = LLDBTest`-[ViewController click2:] at ViewController.m:35, address = 0x000000010be80e40, resolved, hit count = 0
    7.3: where = LLDBTest`-[ViewController click3:] at ViewController.m:40, address = 0x000000010be80e80, resolved, hit count = 0

    8: name = 'test1', locations = 1, resolved = 1, hit count = 0
    8.1: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010be80d00, resolved, hit count = 0

    (lldb) br l 8
    8: name = 'test1', locations = 1, resolved = 1, hit count = 0
    8.1: where = LLDBTest`test1 at ViewController.m:17, address = 0x000000010be80d00, resolved, hit count = 0

    (lldb) br l 7
    7: names = {'[ViewController click1:]', '[ViewController click1:]', '[ViewController click1:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click2:]', '[ViewController click3:]', '[ViewController click3:]', '[ViewController click3:]'}, locations = 3, resolved = 3, hit count = 0
    7.1: where = LLDBTest`-[ViewController click1:] at ViewController.m:31, address = 0x000000010be80e00, resolved, hit count = 0
    7.2: where = LLDBTest`-[ViewController click2:] at ViewController.m:35, address = 0x000000010be80e40, resolved, hit count = 0
    7.3: where = LLDBTest`-[ViewController click3:] at ViewController.m:40, address = 0x000000010be80e80, resolved, hit count = 0

    breakpoint disable/enable/delete

    breakpoint disable

    (lldb) br dis 7.1
    1 breakpoints disabled.


    breakpoint enable

    (lldb) br en 7.1
    1 breakpoints enabled.

    breakpoint delete
    只能删除指定组断点,不能删除组里面的某一个。

    (lldb) br del 7.2
    0 breakpoints deleted; 1 breakpoint locations disabled.
    (lldb) br del 7
    1 breakpoints deleted; 0 breakpoint locations disabled.
    breakpoint delete
    只能删除指定组断点,不能删除组里面的某一个。
    (lldb) br del 7.2
    0 breakpoints deleted; 1 breakpoint locations disabled.
    (lldb) br del 7
    1 breakpoints deleted; 0 breakpoint locations disabled.

    breakpoint delete删除所有断点


    (lldb) breakpoint delete
    About to delete all breakpoints, do you want to do that?: [Y/n] y
    All breakpoints removed. (3 breakpoints)

    ⚠️breakpoint组在一次运行过程中是一直递增的。多次添加断点只会断住一次。

    3.1.2 watchpoint 内存断点/地址断点

    breakpoint是对方法生效的断点,watchpoint是对地址生效的断点。如果地址里中的数据改变了,就让程序中断。

    watchpoint set

    watchpoint set variable


    (lldb) watchpoint set variable item1->_name
    Watchpoint created: Watchpoint 1: addr = 0x600002ce8868 size = 8 state = enabled type = w
    declare @ '/Users/zaizai/LLDBTest/LLDBTest/ViewController.m:28'
    watchpoint spec = 'item1->_name'
    new value: 0x000000010ad37038

    改变name值的时候就会断住(即使值没有变):

    Watchpoint 1 hit:
    old value: 0x0000000109319038
    new value: 0x0000000109319038

    watchpoint set variable传入的是变量名,不接受方法。所以不能使用watchpoint set variable item1.name

    watchpoint set expression

    (lldb) p item1->_name
    (__NSCFConstantString *) $0 = 0x000000010a0d0038 @"1"
    (lldb) p &item1->_name
    (NSString **) $1 = 0x00006000033bec48
    (lldb) watchpoint set expression 0x00006000033bec48
    Watchpoint created: Watchpoint 1: addr = 0x6000033bec48 size = 8 state = enabled type = w
    new value: 4463591480

    breakpoint类似,watchpoint也有watchpoint listwatchpoint disablewatchpoint enablewatchpoint delete

    3.2 lldb代码执行 expression、p、print、call、po

    expression执行一个表达式,并将表达式返回的结果输出。


    expression <cmd-options> -- <expr>
    • cmd-options:命令选项,一般使用默认。
    • --:命令选项结束符。
    • expr:表达式。

    pprintcall 都是expression --的别名:

    • print: 打印某个东西,可以是变量/表达式,pprint的缩写。
    • call: 调用某个方法。

    poexpression -O --的别名。调用的是description或者debugDescription方法。

    进制转换p/x、p/o、p/t
    p除了打印还有常量的进制转换功能。


    //默认10进制打印
    (lldb) p 100
    (int) $0 = 100
    //16进制打印
    (lldb) p/x 100
    (int) $1 = 0x00000064
    //8进制打印
    (lldb) p/o 100
    (int) $2 = 0144
    //2进制打印
    (lldb) p/t 100
    (int) $3 = 0b00000000000000000000000001100100
    //字符串转换为10进制
    (lldb) p/d 'A'
    (char) $4 = 65
    //10进制转换为字符
    (lldb) p/c 65
    (int) $5 = A\0\0\0

    浮点数转换

    (lldb) p/x (double) 180.0
    (double) $6 = 0x4066800000000000

    (lldb) p/f 0x4066800000000000
    (long) $1 = 180

    (lldb) e -f f -- 0x4066800000000000
    (long) $2 = 180

    x/nuf


    (lldb) x self
    0x600002c12180: 29 8a 00 00 01 80 1d 00 00 00 00 00 00 00 00 00 )...............
    0x600002c12190: 0e 00 00 00 00 00 00 00 00 5e 75 01 00 60 00 00 .........^u..`..
    (lldb) x/4gx self
    0x600002c12180: 0x001d800100008a29 0x0000000000000000
    0x600002c12190: 0x000000000000000e 0x0000600001755e00
    (lldb) x/4gw self
    0x600002c12180: 0x00008a29 0x001d8001 0x00000000 0x00000000

    x/nuf
    x就是 memory read 内存读取。

    • nn表示要打印的内存单元的个数。
    • uu表示一个地址单元的长度。iOS是小端模式。
      • b表示单字节
      • h表示双字节
      • w表示四字节
      • g表示八字节。
    • ff表示显示方式。
      • x按十六进制格式显示变量
      • d按十进制显示
      • u按十进制显示无符号整形
      • o按八进制显示变量
      • t按二进制显示变量
      • a按十六进制显示变量
      • i指令地址格式
      • c按字符格式显示变量
      • f按浮点数格式显示变量
    • addr:地址/数据。

    3.3查看堆栈信息

    bt(thread backtrace)

     thread #1, queue = 'com.apple.main-thread', stop reason = step over
    * frame #0: 0x000000010e60fa06 LLDBTest`-[ViewController touchesBegan:withEvent:](self=0x00007fce43806030, _cmd="touchesBegan:withEvent:", touches=1 element, event=0x0000600002168540) at ViewController.m:46:23
    frame #1: 0x00007fff246ca70f UIKitCore`forwardTouchMethod + 321
    frame #2: 0x00007fff246ca5bd UIKitCore`-[UIResponder touchesBegan:withEvent:] + 49
    frame #3: 0x00007fff246d95b5 UIKitCore`-[UIWindow _sendTouchesForEvent:] + 622
    frame #4: 0x00007fff246db6c7 UIKitCore`-[UIWindow sendEvent:] + 4774
    frame #5: 0x00007fff246b5466 UIKitCore`-[UIApplication sendEvent:] + 633
    frame #6: 0x00007fff24745f04 UIKitCore`__processEventQueue + 13895
    frame #7: 0x00007fff2473c877 UIKitCore`__eventFetcherSourceCallback + 104
    frame #8: 0x00007fff2039038a CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
    frame #9: 0x00007fff20390282 CoreFoundation`__CFRunLoopDoSource0 + 180
    frame #10: 0x00007fff2038f764 CoreFoundation`__CFRunLoopDoSources0 + 248
    frame #11: 0x00007fff20389f2f CoreFoundation`__CFRunLoopRun + 878
    frame #12: 0x00007fff203896d6 CoreFoundation`CFRunLoopRunSpecific + 567
    frame #13: 0x00007fff2c257db3 GraphicsServices`GSEventRunModal + 139
    frame #14: 0x00007fff24696cf7 UIKitCore`-[UIApplication _run] + 912
    frame #15: 0x00007fff2469bba8 UIKitCore`UIApplicationMain + 101
    frame #16: 0x000000010e60fff2 LLDBTest`main(argc=1, argv=0x00007ffee15efd20) at main.m:17:12
    frame #17: 0x00007fff2025a3e9 libdyld.dylib`start + 1

    up、down、frame select

    (lldb) up
    frame #3: 0x00007fff246d95b5 UIKitCore`-[UIWindow _sendTouchesForEvent:] + 622
    UIKitCore`-[UIWindow _sendTouchesForEvent:]:
    -> 0x7fff246d95b5 <+622>: lea rax, [rip + 0x628a699c] ; UIApp
    0x7fff246d95bc <+629>: mov rdi, qword ptr [rax]
    0x7fff246d95bf <+632>: mov rsi, qword ptr [rbp - 0x170]
    0x7fff246d95c6 <+639>: mov rdx, r12
    0x7fff246d95c9 <+642>: mov rcx, rbx
    0x7fff246d95cc <+645>: mov r8, r14
    0x7fff246d95cf <+648>: call r13
    0x7fff246d95d2 <+651>: mov ecx, 0x1
    (lldb) down
    frame #2: 0x00007fff246ca5bd UIKitCore`-[UIResponder touchesBegan:withEvent:] + 49
    UIKitCore`-[UIResponder touchesBegan:withEvent:]:
    -> 0x7fff246ca5bd <+49>: mov rdi, rbx
    0x7fff246ca5c0 <+52>: pop rbx
    0x7fff246ca5c1 <+53>: pop r12
    0x7fff246ca5c3 <+55>: pop r14
    0x7fff246ca5c5 <+57>: pop r15
    0x7fff246ca5c7 <+59>: pop rbp
    0x7fff246ca5c8 <+60>: jmp qword ptr [rip + 0x5bf063e2] ; (void *)0x00007fff2018f760: objc_release

    UIKitCore`forwardTouchMethod:
    0x7fff246ca5ce <+0>: push rbp
    (lldb) frame select 10
    frame #10: 0x00007fff2038f764 CoreFoundation`__CFRunLoopDoSources0 + 248
    CoreFoundation`__CFRunLoopDoSources0:
    -> 0x7fff2038f764 <+248>: mov r13d, eax
    0x7fff2038f767 <+251>: jmp 0x7fff2038f7dc ; <+368>
    0x7fff2038f769 <+253>: xor r13d, r13d
    0x7fff2038f76c <+256>: jmp 0x7fff2038f802 ; <+406>
    0x7fff2038f771 <+261>: mov rbx, r14
    0x7fff2038f774 <+264>: mov rdi, qword ptr [rbp - 0x38]
    0x7fff2038f778 <+268>: call 0x7fff20312bcc ; CFArrayGetCount
    0x7fff2038f77d <+273>: mov r15, rax

    这3个命令只是方便我们查看堆栈信息,寄存器还是在断点处。

    frame variable
    查看当前frame参数

    (lldb) frame variable
    (ViewController *) self = 0x00007f8c59405600
    (SEL) _cmd = "touchesBegan:withEvent:"
    (BOOL) enable = NO

    在已经执行过的frame中修改参数不会影响后面的结果。

    thread return

    thread return可以接受一个表达式,调用命令之后直接从当前的frame返回表达式的值。直接返回不执行后面的代码。相当于回滚(相当于直接到bl跳转的下一行汇编代码)。当然修改pc寄存器的值也能达到相同的效果。

    3.4 command指令

    给断点添加命令的命令。
    breakpoint command add


    (lldb) b test2:
    Breakpoint 1: where = LLDBTest`-[ViewController test2:] at ViewController.m:65, address = 0x0000000103e7db40
    (lldb) br command add 1
    Enter your debugger command(s). Type 'DONE' to end.
    > frame variable
    > DONE

    当断点断住的时候执行frame variable指令。
    当然也可以只添加一条指令:


    br command add -o "po self" 1

    多次对同一个断点添加命令,后面命令会将前面命令覆盖


    breakpoint command list
    查看某个断点已有的命令(list 后必须有断点编号)

    (lldb) breakpoint command list 1
    Breakpoint 1:
    Breakpoint commands:
    po self


    3.5 target stop-Hook指令

    target stop-hook命令可以在每次stop的时候去执行一些命令


    (lldb) target stop-hook add -o "frame variable"
    Stop hook #1 added.
    command不同的是它对所有断点生效。相当于对程序下钩子。
    display命令等价:

    (lldb) display frame variable
    Stop hook #2 added.
    target stop-hook只对breakpointwatchpointstop生效,直接点击Xcode上的pause或者debug view hierarchy不会生效

    target stop-hook list


    (lldb) target stop-hook list
    Hook: 1
    State: enabled
    Commands:
    frame variable

    Hook: 2
    State: enabled
    Commands:
    expr -- frame variable

    target stop-hook disable / enable
    暂时让某个stop-hook失效/生效,不传id则代表全部。

    target stop-hook delete / undisplay
    删除stop-hook


    (lldb) target stop-hook delete
    Delete all stop hooks?: [Y/n] y

    delete可以不传idundisplay必须传id


    3.6 image(target modules)指令

    image lookup --address

    查找某个地址具体对应的文件位置,可以使用image lookup --address(image lookup -a)
    比如有一个crash:

    2021-05-19 18:19:45.833183+0800 LLDBTest[41719:24239029] *** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayM objectAtIndexedSubscript:]: index 5 beyond bounds [0 .. 2]'
    *** First throw call stack:
    (
    0 CoreFoundation 0x00007fff20421af6 __exceptionPreprocess + 242
    1 libobjc.A.dylib 0x00007fff20177e78 objc_exception_throw + 48
    2 CoreFoundation 0x00007fff2049e77f _CFThrowFormattedException + 194
    3 CoreFoundation 0x00007fff20320825 -[__NSArrayM removeAllObjects] + 0
    4 LLDBTest 0x0000000107c469f7 -[ViewController touchesBegan:withEvent:] + 151

    从上面的堆栈可以看到是-[ViewController touchesBegan:withEvent:]中的调用发生了crash,但是并不知道在ViewController.m的哪一行。使用image lookup -a就可以具体定位到确定的行数:


    (lldb) image lookup -a 0x0000000107c469f7
    Address: LLDBTest[0x00000001000019f7] (LLDBTest.__TEXT.__text + 807)
    Summary: LLDBTest`-[ViewController touchesBegan:withEvent:] + 151 at ViewController.m:48:28

    image lookup --name

    查找方法或者符号的信息可以使用image lookup --nameimage lookup -n):

    (lldb) image lookup -n test2:
    1 match found in /Users/zaizai/Library/Developer/Xcode/DerivedData/LLDBTest-enxwhkxlnnynraafdlfrcoxaibzm/Build/Products/Debug-iphonesimulator/LLDBTest.app/LLDBTest:
    Address: LLDBTest[0x0000000100001b30] (LLDBTest.__TEXT.__text + 1120)
    Summary: LLDBTest`-[ViewController test2:] at ViewController.m:65

    image lookup --type

    可以使用image lookup --type(image lookup -t)查看类型:

    (lldb) image lookup -t ViewController
    Best match found in /Users/zaizai/Library/Developer/Xcode/DerivedData/LLDBTest-enxwhkxlnnynraafdlfrcoxaibzm/Build/Products/Debug-iphonesimulator/LLDBTest.app/LLDBTest:
    id = {0x100000033}, name = "ViewController", byte-size = 16, decl = ViewController.h:10, compiler_type = "@interface ViewController : UIViewController{
    NSMutableArray * _items;
    }
    @property(nonatomic, readwrite, getter = items, setter = setItems:) NSMutableArray *items;
    @end"


    作者:HotPotCat
    链接:https://www.jianshu.com/p/5f435b1faf4b
    收起阅读 »

    iOS Block循环引用精讲

    前言本篇文章精讲iOS开发中使用Block时一定要注意内存管理问题,很容易造成循环引用。本篇文章的目标是帮助大家快速掌握使用block的技巧。我相信大家都觉得使用block给开发带来了多大的便利,但是有很多开发者对block内存管理掌握得不够好,导致经常出现循...
    继续阅读 »

    前言

    本篇文章精讲iOS开发中使用Block时一定要注意内存管理问题,很容易造成循环引用。本篇文章的目标是帮助大家快速掌握使用block的技巧。

    我相信大家都觉得使用block给开发带来了多大的便利,但是有很多开发者对block内存管理掌握得不够好,导致经常出现循环引用的问题。对于新手来说,出现循环引用时,是很难去查找的,因此通过Leaks不一定能检测出来,更重要的还是要靠自己的分析来推断出来。

    声景一:Controller之间block传值

    现在,我们声明两个控制器类,一个叫ViewController,另一个叫HYBAController。其中,ViewController有一个按钮,点击时会push到HYBAController下。

    先看HYBAController:

    // 公开了一个方法
    - (instancetype)initWithCallback:(HYBCallbackBlock)callback;

    // 非公开的属性,这里放出来只是告诉大家,HYBAController会对这个属性强引用
    @property (nonatomic, copy) HYBCallbackBlock callbackBlock;
    复制代码

    下面分几种小场景来看看循环引用问题:

    @interface ViewController ()

    // 引用按钮只是为了测试
    @property (nonatomic, strong) UIButton *button;
    // 只是为了测试内存问题,引用之。在开发中,有很多时候我们是
    // 需要引用另一个控制器的,因此这里模拟之。
    @property (nonatomic, strong) HYBAController *vc;

    @end

    // 点击button时
    - (void)goToNext {
      HYBAController *vc = [[HYBAController alloc] initWithCallback:^{
        [self.button setTitleColor:[UIColor greenColor] forState:UIControlStateNormal];
      }];
      self.vc = vc;
      [self.navigationController pushViewController:vc animated:YES];
    }
    复制代码

    现在看ViewController这里,这里在block的地方形成了循环引用,因此vc属性得不到释放。分析其形成循环引用的原因(如下图):

    image

    可以简单说,这里形成了两个环:

    • ViewController->强引用了属性vc->强引用了callback->强引用了ViewController

    • ViewController->强引用了属性vc->强引用了callback->强引用了ViewController的属性button

    对于此这问题,我们要解决内存循环引用问题,可以这么解:

    不声明vc属性或者将vc属性声明为weak引用的类型,在callback回调处,将self.button改成weakSelf.button,也就是让callback这个block对viewcontroller改成弱引用。如就是改成如下,内存就可以正常释放了:

    - (void)goToNext {
      __weak __typeof(self) weakSelf = self;
      HYBAController *vc = [[HYBAController alloc] initWithCallback:^{
        [weakSelf.button setTitleColor:[UIColor greenColor] forState:UIControlStateNormal];
      }];
    //  self.vc = vc;
      [self.navigationController pushViewController:vc animated:YES];
    }
    复制代码

    笔者尝试过使用Leaks检测内存泄露,但是全是通过,一个绿色的勾,让你以为内存处理得很好了,实际上内存并得不到释放。

    针对这种场景,给大家提点建议:

    • 在控制器的生命周期viewDidAppear里打印日志:

    - (void)viewDidAppear:(BOOL)animated {
      [super viewDidAppear:animated];

      NSLog(@"进入控制器:%@", [[self class] description]);
    }
    复制代码
    • 在控制器的生命周期dealloc里打印日志:

    - (void)dealloc {
      NSLog(@"控制器被dealloc: %@", [[self class] description]);
    }
    复制代码

    这样的话,只要日志没有打印出来,说明内存得不到释放,就需要学会分析内存引用问题了。

    场景二:Controller与View之间Block传值

    我们先定义一个view,用于与Controller交互。当点击view的按钮时,就会通过block回调给controller,也就反馈到控制器了,并将对应的数据传给控制器以记录:

    typedef void(^HYBFeedbackBlock)(id model);

    @interface HYBAView : UIView

    - (instancetype)initWithBlock:(HYBFeedbackBlock)block;

    @end

    @interface HYBAView ()

    @property (nonatomic, copy) HYBFeedbackBlock block;

    @end

    @implementation HYBAView

    - (void)dealloc {
      NSLog(@"dealloc: %@", [[self class] description]);
    }

    - (instancetype)initWithBlock:(HYBFeedbackBlock)block {
      if (self = [super init]) {
        self.block = block;

        UIButton *button = [UIButton buttonWithType:UIButtonTypeCustom];
        [button setTitle:@"反馈给controller" forState:UIControlStateNormal];
        button.frame = CGRectMake(50, 200, 200, 45);
        button.backgroundColor = [UIColor redColor];
        [button setTitleColor:[UIColor yellowColor] forState:UIControlStateNormal];
        [button addTarget:self action:@selector(feedback) forControlEvents:UIControlEventTouchUpInside];
        [self addSubview:button];
      }

      return self;
    }

    - (void)feedback {
      if (self.block) {
        // 传模型回去,这里没有数据,假设传nil
        self.block(nil);
      }
    }

    @end
    复制代码

    接下来看HYBAController,增加了两个属性,在viewDidLoad时,创建了aView属性:

    @interface HYBAController()

    @property (nonatomic, copy) HYBCallbackBlock callbackBlock;

    @property (nonatomic, strong) HYBAView *aView;
    @property (nonatomic, strong) id currentModel;

    @end

    @implementation HYBAController

    - (instancetype)initWithCallback:(HYBCallbackBlock)callback {
      if (self = [super init]) {
        self.callbackBlock = callback;
      }

      return self;
    }

    - (void)viewDidLoad {
      [super viewDidLoad];

      self.title = @"HYBAController";
      self.view.backgroundColor = [UIColor whiteColor];

      self.aView = [[HYBAView alloc] initWithBlock:^(id model) {
        // 假设要更新model
        self.currentModel = model;
      }];
      // 假设占满全屏
      self.aView.frame = self.view.bounds;
      [self.view addSubview:self.aView];
      self.aView.backgroundColor = [UIColor whiteColor];
    }

    - (void)viewDidAppear:(BOOL)animated {
      [super viewDidAppear:animated];

      NSLog(@"进入控制器:%@", [[self class] description]);
    }

    - (void)dealloc {
      NSLog(@"控制器被dealloc: %@", [[self class] description]);
    }

    @end
    复制代码

    关于上一场景所讲的循环引用已经解决了,因此我们这一小节的重点就放在controller与view的引用问题上就可以了。

    分析:如下图所示:

    image

    所形成的环有:

    • vc->aView->block->vc(self)

    • vc->aView->block->vc.currentModel

    解决的办法可以是:在创建aView时,block内对currentModel的引用改成弱引用:

    __weak __typeof(self) weakSelf = self;
    self.aView = [[HYBAView alloc] initWithBlock:^(id model) {
        // 假设要更新model
        weakSelf.currentModel = model;
    }];
    复制代码

    我见过很多类似这样的代码,直接使用成员变量,而不是属性,然后他们以为这样就不会引用self,也就是控制器,从而不形成环:

    self.aView = [[HYBAView alloc] initWithBlock:^(id model) {
        // 假设要更新model
        _currentModel = model;
    }];
    复制代码

    这是错误的理解,当我们引用了_currentModel时,它是控制器的成员变量,因此也就引用了控制器。要解决此问题,也是要改成弱引用:

    __block __weak __typeof(_currentModel) weakModel = _currentModel;
    self.aView = [[HYBAView alloc] initWithBlock:^(id model) {
      // 假设要更新model
      weakModel = model;
    }];
    复制代码

    这里还要加上__block哦!

    模拟循环引用

    假设下面如此写代码,是否出现内存得不到释放问题?(其中,controller属性都是强引用声明的)

    @autoreleasepool {
      A *aVC = [[A alloc] init];
      B *bVC = [[B allcok] init];
      aVC.controller = bVC;
      bVC.controller = aVC;
    }
    复制代码

    分析:

    aVC->强引用了bVC->强引用了aVC,因此形成了一个环,导致内存得不到释放。

    写在最后

    本篇文章就讲这么多吧,写本篇文章的目的是教大家如何分析内存是否形成环,只要懂得了如何去分析内存是否循环引用了,那么在开发时一定会特别注意内存管理问题,而且查找内存相关的问题的bug时,也比较轻松。

    源代码

    本篇写了个小demo来测试的,如果只看文章不是很明白的话,如果下载demo来运行看一看,可以帮助您加深对block内存引用问题的理解。

    下载地址:https://github.com/CoderJackyHuang/iOSBlockUseDemo

    链接:http://www.cocoachina.com/articles/15349

    收起阅读 »

    iOS 如何正确的使用NSTimer

    1、属性传值循环引用 如:- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { TabelVie...
    继续阅读 »



    1、属性传值循环引用

    如:- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    TabelViewCell *cell = ...
    cell.tableView = tableView;
    return cell;
    }

    @interface TableViewCell: UITableViewCell
    @property (nonatomic, strong) UITableView *tableView;
    @end

    cell 添加到tableView上被tanleView强引用,cell中tableView被强引用,造成循环引用;

    所以cell中tableView应该用weak关键字

    2、delegate属性用strong关键字循环引用

    定义:
    @interface Class A: NSObject
    @property (nonatomic, strong) BView *someView;
    @property (nonatomic, strong) XXXDelegate delegate;

    调用:
    self.someView.delegate = self;

    class A强引用BView, BView的代理指向A,因为delegate是strong关键字修饰,所以BView会强引用A的实例,造成循环引用

    所以delegate关键字应该用weak修饰


    3、block接获变量,循环引用

    self.block = ^{
    self.someProperty = xxx;
    }

    self持有block,block截获self(这一点我也没搞太明白),赋值会把block copy到堆上,持有相关变量,就是self持有block,block又持有self,形成循环引用

    解决方式:

    __weak typeOf(self) weakSelf = self;
    self.block = ^{
    weakSelf.someProperty = xxx;
    }

    延展:

    但这种方式可能会造成内存提前回收,比如说:block中不是简单的赋值操作,而是一个耗时的网络请求,block中的操作还没有结束,self被释放掉了,这个时候seakSelf为nil,所以someProperty无法赋值

    解决方式:

    __weak typeOf(self) weakSelf = self;
    self.block = ^{
    __strong typeOf(self) strongSelf = weakSelf;
    strongSelf.someProperty = xxx;
    }

    原理: block中强引用self,所以self在要被释放时检查到引用计数不为0,所以系统无法释放self实例,只有等待block执行完毕,block内存被释放的时候,才会释放掉self,所以someProperty赋值成功

    4、NSTimer循环引用

    例如:

    class ViewController
    @property (nonatomic, strong) NSTimer *timer;

    调用:
    self.timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerRun) userInfo:nil repeats:YES];

    这个类似于delegate,把timer的target指向了self,所以可以用weak的方式解除循环引用;

    但是在实际的代码使用过程中,timer一般需要加入到runloop中,被runloop强引用,所以timer强引用viewController, runloop强引用timer,ViewController pop后无法释放,具体可以查看这篇博客对NSTimer的分析https://www.jianshu.com/p/d4589134358a

    所以可以采用另一种方式解决循环引用:

    __weak typeof(self) weakSelf = self;
    self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
    [weakSelf timerRun];
    }];

    注意:timer释放前需要invalidate


    链接:https://www.zhihu.com/question/34123925/answer/1181846112

    收起阅读 »

    iOS 空指针 野指针 僵尸对象

     一些基础的知识,经常混淆,特整理下 空指针:1. 没有存储任何内存地址的指针就称为空指针(NULL指针)。2.被赋值为nil的指针,在没有被具体初始化之前,为nil。注意: nil和Null区别不是初始化前后的区别,是nil代表对象类型的...
    继续阅读 »

     一些基础的知识,经常混淆,特整理下

    空指针:

    1. 没有存储任何内存地址的指针就称为空指针(NULL指针)。

    2.被赋值为nil的指针,在没有被具体初始化之前,为nil。

    注意: 

    nil和Null区别不是初始化前后的区别,是nil代表对象类型的空指针,Null代表基本数据类型的空指针。

    3.nil、Nil、NULL、NSNULL的含义和区别

    nil:OC中的对象的空指针

    Nil:OC中类的空指针

    NULL:C类型的空指针

    NSNull:数值类的空对象

    此处说一下NSNull,在集合中不能nil值,因为NSArray和NSDictionary中nil有特殊的含义。但是有些时候,需要在集合中存放空值,比如个人信息中,只知道姓名,不知道电话号码,此时,有必要将电话号码设置为空,这时,就用到了NSNull。

    NSNull中只有一个null方法 :[NSNull null]


    [dic setObject:[NSNull null] forKey:@"phoneNumber"];


    if(phoneNumber == [NSNull null]){


    //...


    }


    野指针:

    1."野指针"不是nil指针,是指向"垃圾"内存(不可用内存)的指针。野指针是非常危险的。


    示例:


    Student *stu = [[Student alloc] init];


    [stu setAge:10];


    [stu release];这里已经释放内存


    [stu setAge:10];---》报错



    如果改动一下代码,就不会报错


    Student *stu = [[Student alloc] init];


    [stu setAge:10];


    [stu release];


    stu = nil; 


    [stu setAge:10]; //消息是无法发送出去的,不会造成任何的影响,当然也不会报错。



    补充说明:

    1.Student对象接收到release消息后,会马上被销毁,所占用的内存会被回收。” 这里执行release只是标记对象占用的那块内存可以被释放,但是具体的释放的时间是不可控的,如果在release之后执行[stu setAge:10];不一定会野指针crash,如果对象内存已经被其他对象覆写占用,那么会crash,如果没有没覆写,调用依然可以正确执行。

    2.向空指针发送消息不会报错,但是给野指针发送消息会报错

    僵尸对象

    遇到exc_bad_access这类问题一般都是僵尸对象引起的,可以开启僵尸模式定位,我们并没有保留他,只是在程序运行到该对象的时候会产生问题,没有谁会运用他,只会定位他然后解决掉

    内存回收的本质.

    1.申请一块空间,实际上是向系统申请一块别人不再使用的空间.

    2.释放一块空间,指的是占用的空间不再使用,这个时候系统可以分配给别人去使用.

    3.在这个个空间分配给别人之前 数据还是存在的.

        3.1.OC对象释放以后,表示OC对象占用的空间可以分配给别人.

        3.2.但是再分配给别人之前 这个空间仍然存在 对象的数据仍然存在.

    4.僵尸对象: 一个已经被释放的对象 就叫做僵尸对象.

    使用野指针访问僵尸对象.有的时候会出问题,有的时候不会出问题.

    1.当野指针指向的僵尸对象所占用的空间还没有分配给别人的时候,这个时候其实是可以访问的.

    因为对象的数据还在.

    2.当野指针指向的对象所占用的空间分配给了别人的时候 这个时候访问就会出问题.

    3.所以,你不要通过一个野指针去访问一个僵尸对象.

           3.1.虽然可以通过野指针去访问已经被释放的对象,但是我们不允许这么做.

    僵尸对象检测.

    1.默认情况下. Xcode不会去检测指针指向的对象是否为一个僵尸对象. 能访问就访问 不能访问就报错.

    2.可以开启Xcode的僵尸对象检测.

           2.1.那么就会在通过指针访问对象的时候,检测这个对象是否为一个僵尸对象 如果是僵尸对象 就会报错.

    为什么不默认开启僵尸对象检测呢?

    1.因为一旦开启,每次通过指针访问对象的时候.都会去检查指针指向的对象是否为僵尸对象.那么这样的话 就影响效率了.

    如何避免僵尸对象报错.

    1.当一个指针变为野指针以后. 就把这个指针的值设置为nil

    僵尸对象无法复活.

    1.当一个对象的引用计数器变为0以后 这个对象就被释放了.

    2.就无法取操作这个僵尸对象了. 所有对这个对象的操作都是无效的.

    3.因为一旦对象被回收 对象就是1个僵尸对象 而访问1个僵尸对象 是没有意义.



    链接:https://www.jianshu.com/p/30e59628b391
    收起阅读 »

    iOS 内存优化

    简述: 本应释放的内存没有释放,导致可用空间减少的现象。 举个例子:你dismiss了一个视图控制器,但是最终却没有执行这个视图控制器的dealloc方法,就会导致内存泄露。 目前遇到的导致内存泄漏比较严重的有这几个地方: 1. Timer NSTimer经常...
    继续阅读 »

    简述:


    本应释放的内存没有释放,导致可用空间减少的现象。

    举个例子:你dismiss了一个视图控制器,但是最终却没有执行这个视图控制器的dealloc方法,就会导致内存泄露。

    目前遇到的导致内存泄漏比较严重的有这几个地方:


    1. Timer


    NSTimer经常会被作为某个类的成员变量,而NSTimer初始化时要指定self为target,容易造成循环引用。 另一方面,若timer一直处于validate的状态,则其引用计数将始终大于0。

    - (instancetype)init {
    self = [super init];
    if (self) {
    _timer = [NSTimer timerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
    NSLog(@"%@ called!", [self class]);
    }];
    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
    }
    return self;
    }

    - (void)viewDidLoad {
    [super viewDidLoad];

    self.view.backgroundColor = [UIColor yellowColor];

    [_timer fire];
    }

    - (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    // 控制器视图将要消失的时候清除 timer 不失为一个好时机。
    [self cleanTimer];
    }

    - (void)cleanTimer {
    [_timer invalidate];
    _timer = nil;
    }

    - (void)dealloc {
    // 应该在更合适的地方释放掉timer,否则会造成循环引用,导致控制器无法释放
    // [self cleanTimer];
    NSLog(@"%@ dealloc!!!", [self class]);
    }

    这个例子中控制器无法释放,造成内存泄漏,原因如下:

    从timer的角度,timer认为调用方(控制器)被析构时会进入 dealloc,在 dealloc 可以顺便将 timer 的计时停掉并且释放内存;

    但是从控制器的角度,他认为 timer 不停止计时不析构,那我永远没机会进入 dealloc。循环引用,互相等待,子子孙孙无穷尽也。

    问题的症结在于-(void)cleanTimer函数的调用时机不对,显然不能想当然地放在调用者的 dealloc 中。一个比较好的解决方法是开放这个函数,在更合适的位置(比如在- (void)viewWillDisappear:(BOOL)animated;中)调用来清理现场。


    2. Delegate


    开发过程中使用retain修饰符或无修饰符(无修饰符默认strong),导致很多应该释放的视图控制器都没释放。这个修改很简单:将修饰符改成weak即可。

    注:为什么不用assign, 如果用assign声明的变量在栈中可能不会自动赋值为nil,就会造成野指针错误!

    weak声明的变量在栈中就会自动清空,赋值为nil。

    // 如果此处用 retain 修饰,则添加这个代理方法的控制器就会由于 delegate 没有清空而无法释放,造成内存泄露。
    //@property (retain, nonatomic) DelegateViewDelegate delegate;
    @property (weak, nonatomic) DelegateViewDelegate delegate;

    3. Block


    block容易出现内存泄露,根本原因是存在对象间的循环引用问题(对象a强引用对象b,对象b强引用对象a)。


    举例说明:

    创建一个对象并为对象添加一个block属性

    @interface BlockObject : NSObject

    @property (copy, nonatomic) dispatch_block_t block;

    @end

    为控制器添加三个属性,其中包括新创建的对象属性

    @interface BlockViewController ()

    // self 对 object 对象进行强引用
    @property (strong, nonatomic) BlockObject *object;
    @property (assign, nonatomic) NSInteger index;
    @property (copy, nonatomic) dispatch_block_t block;

    @end

    造成内存泄露写法一:

    _object = [[BlockObject alloc] init];

    [_object setBlock:^{
    // object 对象对 self (成员变量或属性)进行强引用,就会造成循环引用
    self.index = 1; // _index = 1;
    }];

    解决方式:

    _object = [[BlockObject alloc] init];

    // 先将 self 转成 weak,之后在 block 内部转成 strong 使用,是常见的解决方案。
    __weak typeof(self)weakSelf = self;
    [_object setBlock:^{
    __strong typeof(self)strongSelf = weakSelf;
    strongSelf.index = 1;
    }];

    用全局变量的写法也会造成内存泄露:

    - (void)viewDidLoad {
    [super viewDidLoad];

    // 此处会发生内存泄露,因为 self 添加了全局 block,self 对此 block 存在强引用。
    [self executeBlock2:^{
    self.index = 1;
    }];
    }

    - (void)executeBlock2:(dispatch_block_t)block {
    // 这个 _block 全局变量就是内存泄露的原因,如果 block 内部使用weakSelf就会打破这个循环了。
    _block = block;
    if (block) {
    block();
    }
    }

    4. Image


    关于图片加载占用内存问题:

    imageNamed: 方法会在内存中缓存图片,用于常用的图片。

    imageWithContentsOfFile: 方法在视图销毁的时候会释放图片占用的内存,适合不常用的大图等。

    #pragma mark - 图片加载内存占用问题 -
    // 初始化时内存占用为 42M
    // 加载之后为 56M,控制器dealloc 之后内存并没有明显减少
    cell.imageView.image = [UIImage imageNamed:imageName];

    // 加载之后为 56M,控制器dealloc 之后内存明显减少,回到之前水平 44M 左右
    NSString *file = [[NSBundle mainBundle] pathForResource:imageName ofType:nil];
    cell.imageView.image = [UIImage imageWithContentsOfFile:file];

    所以需要时刻注意图片操作是否合理,避免大量占用内存。

    注意:





    1. imageWithContentsOfFile: 方法无法读取.xcassets里的图片。


    2. imageWithContentsOfFile: 方法读取图片需要加文件后缀名如png,jpg等。



    5. Table View


    Table view需要有很好的滚动性能,不然用户会在滚动过程中发现动画的瑕疵。

    为了保证table view平滑滚动,确保你采取了以下的措施:



    1.正确使用reuseIdentifier来重用cells。

    2.将所有不需要透明的视图 opaque(不透明)设置为YES,包括cell自身。

    3.缓存行高。

    4.如果cell内现实的内容来自web,使用异步加载,缓存请求结果。

    5.使用shadowPath来画阴影。

    6.减少subviews的数量。

    7.尽量不适用cellForRowAtIndexPath:,如果你需要用到它,只用一次然后缓存结果。

    8.使用正确的数据结构来存储数据。

    9.使用rowHeight, sectionFooterHeightsectionHeaderHeight来设定固定的高,不要请求delegate。



    6. 不要阻塞主线程


    永远不要使主线程承担过多。因为UIKit在主线程上做所有工作,渲染,管理触摸反应,回应输入等都需要在它上面完成。

    一直使用主线程的风险就是如果你的代码真的block了主线程,你的app会失去反应。

    大部分阻碍主进程的情形是你的app在做一些牵涉到读写外部资源的I/O操作,比如存储或者网络。


    7. 选择正确的Collection


    学会选择对业务场景最合适的类或者对象是写出能效高的代码的基础。当处理collections时这句话尤其正确。

    一些常见collection的总结:



    • Arrays: 有序的一组值。使用index来lookup很快,使用value lookup很慢,插入/删除很慢。

    • Dictionaries: 存储键值对。用键来查找比较快。

    • Sets: 无序的一组值。用值来查找很快,插入/删除很快。


    8. 打开gzip压缩


    大量app依赖于远端资源和第三方API,你可能会开发一个需要从远端下载XML, JSON, HTML或者其它格式的app。

    问题是我们的目标是移动设备,因此你就不能指望网络状况有多好。一个用户现在还在edge网络,下一分钟可能就切换到了3G。不论什么场景,你肯定不想让你的用户等太长时间。

    减小文档的一个方式就是在服务端和你的app中打开gzip。这对于文字这种能有更高压缩率的数据来说会有更显著的效用。

    好消息是,iOS已经在NSURLConnection中默认支持了gzip压缩,当然AFNetworking这些基于它的框架亦然。像Google App Engine这些云服务提供者也已经支持了压缩输出。


    9. 重用和延迟加载(lazy load) Views


    更多的view意味着更多的渲染,也就是更多的CPU和内存消耗,对于那种嵌套了很多view在UIScrollView里边的app更是如此。

    这里我们用到的技巧就是模仿UITableViewUICollectionView的操作:不要一次创建所有的subview,而是当需要时才创建,当它们完成了使命,把他们放进一个可重用的队列中。

    这样的话你就只需要在滚动发生时创建你的views,避免了不划算的内存分配。

    创建views的能效问题也适用于你app的其它方面。想象一下一个用户点击一个按钮的时候需要呈现一个view的场景。有两种实现方法:



    1. 创建并隐藏这个view当这个screen加载的时候,当需要时显示它;

    2. 当需要时才创建并展示。

      每个方案都有其优缺点。用第一种方案的话因为你需要一开始就创建一个view并保持它直到不再使用,这就会更加消耗内存。然而这也会使你的app操作更敏感因为当用户点击按钮的时候它只需要改变一下这个view的可见性。

      第二种方案则相反-消耗更少内存,但是会在点击按钮的时候比第一种稍显卡顿。


    10. 处理内存警告


    一旦系统内存过低,iOS会通知所有运行中app。在官方文档中是这样记述:

    如果你的app收到了内存警告,它就需要尽可能释放更多的内存。最佳方式是移除对缓存,图片object和其他一些可以重创建的objects的strong references.


    幸运的是,UIKit提供了几种收集低内存警告的方法:



    • 在app delegate中使用applicationDidReceiveMemoryWarning:的方法

    • 在你的自定义UIViewController的子类(subclass)中覆盖didReceiveMemoryWarning

    • 注册并接收 UIApplicationDidReceiveMemoryWarningNotification的通知

      一旦收到这类通知,你就需要释放任何不必要的内存使用。


    例如,UIViewController的默认行为是移除一些不可见的view,它的一些子类则可以补充这个方法,删掉一些额外的数据结构。一个有图片缓存的app可以移除不在屏幕上显示的图片。

    这样对内存警报的处理是很必要的,若不重视,你的app就可能被系统杀掉。

    然而,当你一定要确认你所选择的object是可以被重现创建的来释放内存。一定要在开发中用模拟器中的内存提醒模拟去测试一下。


    11. 重用大开销对象


    一些objects的初始化很慢,比如NSDateFormatter和NSCalendar。然而,你又不可避免地需要使用它们,比如从JSON或者XML中解析数据。

    想要避免使用这个对象的瓶颈你就需要重用他们,可以通过添加属性到你的class里或者创建静态变量来实现。

    注意如果你要选择第二种方法,对象会在你的app运行时一直存在于内存中,和单例(singleton)很相似。


    Demo地址:iOS 内存优化

    链接:https://www.jianshu.com/p/f46f61180f79
    收起阅读 »

    IO系列 字节、字符流|Java基础

    字节缓冲流介绍BufferOutputStream:该类实现缓冲输出流。 通过设置这样的输出流,应用程序可以向底层输出流写入字节,而不必为写入的每个字节导致底层系统的调用BufferedInputStream:创建BufferedInputStream将创建一...
    继续阅读 »

    字节缓冲流介绍

    • BufferOutputStream:该类实现缓冲输出流。 通过设置这样的输出流,应用程序可以向底层输出流写入字节,而不必为写入的每个字节导致底层系统的调用

    • BufferedInputStream:创建BufferedInputStream将创建一个内部缓冲区数组。 当从流中读取或跳过字节时,内部缓冲区将根据需要从所包含的输入流中重新填充,一次多个

    构造方法

    方法名说明
    BufferedOutputStream(OutputStream out)创建字节缓冲输出流对象
    BufferedInputStream(InputStream in)创建字节缓冲输入流对象
    public class BufferStreamLearn1 {
    public static void main(String[] args) throws IOException {
    //字节缓冲输出流:BufferedOutputStream(OutputStream out)

    BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("File\shixf.txt"));
    //写数据
    bos.write("hello\r\n".getBytes());
    bos.write("world\r\n".getBytes());
    //释放资源
    bos.close();


    //字节缓冲输入流:BufferedInputStream(InputStream in)
    BufferedInputStream bis = new BufferedInputStream(new FileInputStream("File\shixf.txt"));

    //一次读取一个字节数据
    /*int by;
    while ((by=bis.read())!=-1) {
    System.out.print((char)by);
    }*/


    //一次读取一个字节数组数据
    byte[] bys = new byte[1024];
    int len;
    while ((len=bis.read(bys))!=-1) {
    System.out.print(new String(bys,0,len));
    }

    //释放资源
    bis.close();
    }
    }

    文件输出image.png

    控制台输出image.png

    字符流

    字符流介绍

    • 字符流的介绍

      由于字节流操作中文不是特别的方便,所以Java就提供字符流

      字符流 = 字节流 + 编码表

    • 中文的字节存储方式

      用字节流复制文本文件时,文本文件也会有中文,但是没有问题,原因是最终底层操作会自动进行字节拼接成中文,如何识别是中文的呢?

      汉字在存储的时候,无论选择哪种编码存储,第一个字节都是负数

    编码表

    • 什么是字符集

    是一个系统支持的所有字符的集合,包括各国家文字、标点符号、图形符号、数字等

    l计算机要准确的存储和识别各种字符集符号,就需要进行字符编码,一套字符集必然至少有一套字符编码。常见字符集有ASCII字符集、GBXXX字符集、Unicode字符集等

    • 常见的字符集

      • ASCII字符集:

        lASCII:是基于拉丁字母的一套电脑编码系统,用于显示现代英语,主要包括控制字符(回车键、退格、换行键等)和可显示字符(英文大小写字符、阿拉伯数字和西文符号)

        基本的ASCII字符集,使用7位表示一个字符,共128字符。ASCII的扩展字符集使用8位表示一个字符,共256字符,方便支持欧洲常用字符。是一个系统支持的所有字符的集合,包括各国家文字、标点符号、图形符号、数字等

      • GBXXX字符集:

        GBK:最常用的中文码表。是在GB2312标准基础上的扩展规范,使用了双字节编码方案,共收录了21003个汉字,完全兼容GB2312标准,同时支持繁体汉字以及日韩汉字等

      • Unicode字符集:

        UTF-8编码:可以用来表示Unicode标准中任意字符,它是电子邮件、网页及其他存储或传送文字的应用 中,优先采用的编码。互联网工程工作小组(IETF)要求所有互联网协议都必须支持UTF-8编码。它使用一至四个字节为每个字符编码

        编码规则:

        128个US-ASCII字符,只需一个字节编码

        拉丁文等字符,需要二个字节编码

        大部分常用字(含中文),使用三个字节编码

        其他极少使用的Unicode辅助字符,使用四字节编码

    字符串中的编码解码问题

    方法名说明
    byte[] getBytes()使用平台的默认字符集将该 String编码为一系列字节
    byte[] getBytes(String charsetName)使用指定的字符集将该 String编码为一系列字节
    String(byte[] bytes)使用平台的默认字符集解码指定的字节数组来创建字符串
    String(byte[] bytes, String charsetName)通过指定的字符集解码指定的字节数组来创建字符串
    public class StringLearn {
    public static void main(String[] args) throws UnsupportedEncodingException {
    //定义一个字符串
    String s = "中国";

    //byte[] bys = s.getBytes(); //[-28, -72, -83, -27, -101, -67]
    //byte[] bys = s.getBytes("UTF-8"); //[-28, -72, -83, -27, -101, -67]
    byte[] bys = s.getBytes("GBK"); //[-42, -48, -71, -6]
    System.out.println(Arrays.toString(bys));

    //String ss = new String(bys);
    //String ss = new String(bys,"UTF-8");
    String ss = new String(bys,"GBK");
    System.out.println(ss);
    }
    }

    image.png

    字符流中的编码解码

    • InputStreamReader:是从字节流到字符流的桥梁

      它读取字节,并使用指定的编码将其解码为字符

      它使用的字符集可以由名称指定,也可以被明确指定,或者可以接受平台的默认字符集

    • OutputStreamWriter:是从字符流到字节流的桥梁

      是从字符流到字节流的桥梁,使用指定的编码将写入的字符编码为字节

      它使用的字符集可以由名称指定,也可以被明确指定,或者可以接受平台的默认字符集

    方法名说明
    InputStreamReader(InputStream in)使用默认字符编码创建InputStreamReader对象
    InputStreamReader(InputStream in,String chatset)使用指定的字符编码创建InputStreamReader对象
    OutputStreamWriter(OutputStream out)使用默认字符编码创建OutputStreamWriter对象
    OutputStreamWriter(OutputStream out,String charset)使用指定的字符编码创建OutputStreamWriter对象
    public class ConversionStreamLearn {
    public static void main(String[] args) throws IOException {
    //OutputStreamWriter osw = new OutputStreamWriter(new FileOutputStream("myCharStream\osw.txt"));
    OutputStreamWriter osw = new OutputStreamWriter(new FileOutputStream("File\shixf.txt"),"GBK");
    osw.write("中国");
    osw.close();

    //InputStreamReader isr = new InputStreamReader(new FileInputStream("myCharStream\osw.txt"));
    try (InputStreamReader isr = new InputStreamReader(new FileInputStream("File\shixf.txt"), "GBK")) {
    //一次读取一个字符数据
    int ch;
    while ((ch = isr.read()) != -1) {
    System.out.print((char) ch);
    }
    isr.close();
    }
    }
    }

    文件输出image.png

    控制台输出image.png

    字符流写数据的5种方式

    方法名说明
    void write(int c)写一个字符
    void write(char[] cbuf)写入一个字符数组
    void write(char[] cbuf, int off, int len)写入字符数组的一部分
    void write(String str)写一个字符串
    void write(String str, int off, int len)写一个字符串的一部分
     
    方法名说明
    flush()刷新流,之后还可以继续写数据
    close()关闭流,释放资源,但是在关闭之前会先刷新流。一旦关闭,就不能再写数据
    public class OutputStreamWriterLearn {
    public static void main(String[] args) throws IOException {
    OutputStreamWriter osw = new OutputStreamWriter(new FileOutputStream("File\shixf.txt"));

    //void write(int c):写一个字符
    // osw.write(97);
    // osw.write(98);
    // osw.write(99);

    //void writ(char[] cbuf):写入一个字符数组
    char[] chs = {'a', 'b', 'c', 'd', 'e'};
    // osw.write(chs);

    //void write(char[] cbuf, int off, int len):写入字符数组的一部分
    // osw.write(chs, 0, chs.length);
    // osw.write(chs, 1, 3);

    //void write(String str):写一个字符串
    // osw.write("abcde");

    //void write(String str, int off, int len):写一个字符串的一部分
    // osw.write("abcde", 0, "abcde".length());
    osw.write("abcde", 1, 3);

    //释放资源
    osw.close();
    }
    }

    字符流读数据的2种方式

    方法名说明
    int read()一次读一个字符数据
    int read(char[] cbuf)一次读一个字符数组数据
    public class InputStreamReaderLearn {
    public static void main(String[] args) throws IOException {
    // shixf.txt -> {b,c,d}
    InputStreamReader isr = new InputStreamReader(new FileInputStream("File\shixf.txt"));

    //int read():一次读一个字符数据
    // int ch;
    // while ((ch=isr.read())!=-1) {
    // System.out.print((char)ch);
    // }

    //int read(char[] cbuf):一次读一个字符数组数据
    char[] chs = new char[1024];
    int len;
    while ((len = isr.read(chs)) != -1) {
    System.out.print(new String(chs, 0, len));
    }

    //释放资源
    isr.close();
    }
    }

    控制台输出

    image.png

    字符缓冲流

    字符缓冲流介绍

    • BufferedWriter:将文本写入字符输出流,缓冲字符,以提供单个字符,数组和字符串的高效写入,可以指定缓冲区大小,或者可以接受默认大小。默认值足够大,可用于大多数用途

    • BufferedReader:从字符输入流读取文本,缓冲字符,以提供字符,数组和行的高效读取,可以指定缓冲区大小,或者可以使用默认大小。 默认值足够大,可用于大多数用途

    方法名说明
    BufferedWriter(Writer out)创建字符缓冲输出流对象
    BufferedReader(Reader in)创建字符缓冲输入流对象
    public class BufferedStreamLearn {
    public static void main(String[] args) throws IOException {
    //BufferedWriter(Writer out)
    BufferedWriter bw = new BufferedWriter(new FileWriter("File\ShixfBW.txt"));
    bw.write("hello\r\n");
    bw.write("world\r\n");
    bw.close();

    //BufferedReader(Reader in)
    BufferedReader br = new BufferedReader(new FileReader("File\ShixfBW.txt"));

    //一次读取一个字符数据
    // int ch;
    // while ((ch=br.read())!=-1) {
    // System.out.print((char)ch);
    // }

    //一次读取一个字符数组数据
    char[] chs = new char[1024];
    int len;
    while ((len=br.read(chs))!=-1) {
    System.out.print(new String(chs,0,len));
    }

    br.close();
    }
    }

    文件输出image.png

    控制台输出image.png

    字符缓冲流特有功能

    BufferedWriter

    方法名说明
    void newLine()写一行行分隔符,行分隔符字符串由系统属性定义

    BufferedReader:

    方法名说明
    String readLine()读一行文字。 结果包含行的内容的字符串,不包括任何行终止字符如果流的结尾已经到达,则为null
    public class BufferedStreamLearn {
    public static void main(String[] args) throws IOException {

    //创建字符缓冲输出流
    BufferedWriter bw = new BufferedWriter(new FileWriter("File\ShixfBW.txt"));

    //写数据
    for (int i = 0; i < 10; i++) {
    bw.write("hello" + i);
    //bw.write("\r\n");
    bw.newLine();
    bw.flush();
    }

    //释放资源
    bw.close();

    //创建字符缓冲输入流
    BufferedReader br = new BufferedReader(new FileReader("File\ShixfBW.txt"));

    String line;
    while ((line=br.readLine())!=null) {
    System.out.println(line);
    }

    br.close();
    }
    }

    输出

    image.png

    小结

    image.pngimage.png


    收起阅读 »

    【JVM入门食用指南】JVM内存管理

    📑即将学会JVM的内存管理的相关知识点,JVM对内存管理进行了哪些规范Java从编译到执行.java文件经过javac编译成.class文件 .class文件通过类加载器(ClassLoader)加载到方法区 jvm执行引擎执行 把字节码翻译成机器码...
    继续阅读 »

    📑即将学会

    JVM的内存管理的相关知识点,JVM对内存管理进行了哪些规范

    Java从编译到执行

    image.png

    .java文件经过javac编译成.class文件 .class文件通过类加载器(ClassLoader)加载到方法区 jvm执行引擎执行 把字节码翻译成机器码

    解释执行与JIT执行

    解释执行

    JVM 是C++ 写的 需要通过C++ 解释器进行解释

    解释执行优缺点

    通过JVM解释 速度相对慢一些

    JIT (just-in-time compilation 即时编译)(hotspot)

    方法、一段代码 循环到一定次数 后端1万多 代码会走hotspot编译 JIT执行(hotspot)(JIT) java代码 直接翻译成(不经解释器) 汇编码 机器码

    JIT执行优缺点

    速度快 但是编译需要一定时间

    JVM是一种规范

    JVM两种特性 跨平台 语言无关性

    • 跨平台
      • 相同的代码在不同的平台有相同的执行效果
    • JVM语言无关性
      • 只识别.class文件 只要把相关的语言文件编译成.class文件 就可以通过JVM执行
        • 像groove、kotlin、java语言 本质上和语言没有关系,因此,只要符合JVM规范,就可以执行 语言层面上 只是将.java .kt等文件编译成.class文件

    因此 JVM是一种规范

    JVM 内存管理规范

    运行时数据区域

    Java虚拟机在执行Java程序的过程中会把它所管理的内存 划分为若干个不同的数据区域 数据划分 image.png 而数据划分这块 依据线程私有 和 线程共享这两种进行划分

    • 线程共享区
      • 线程共享区 分为方法区 和 堆
    • 方法区 (永久代(JDk1.7 前) 元空间(JDK1.8) hotspot实现下称呼 )
      • 在JVM规范中,统称为方法区
        • 只是hotSpot VM 这块产品用得比较多 。hotSpot利用永久代 或者 元空间 实现method区 Hotspot不同的版本实现而已

     几乎所有对象都会在这里分配

    线程私有区 每启动一个线程划分的一个区域

    直接内存 堆外内存

    • JVM会在运行时把管理的区域进行虚拟化 new 对象()

    通过JVM内存管理,将对象放入堆中,使用的时候只需要找到对象的引用 就可以直接使用,比直接通过分配内存,地址寻址 计算偏移量,偏移长度更方便。

    • 而这块数据没有经过内存虚拟化 (运行时外的内存 内存8G JVM 占用5G 堆外内存3G)

    可以通过某种方法 进行申请、使用、释放。不过比较麻烦,涉及分配内存、分配地址等

    java方法的运行与虚拟机栈

    虚拟机栈

    栈的结构 存储当前线程运行Java方法所需要的数据、指令、返回地址

    public static void main(String[] args) {
    A();
    }

    private static void A() {
    B();
    }

    private static void B() {
    C();
    }

    private static void C() {

    }

    比如以上代码,当我们运行main方法时,会启动一个线程,这个时候,JVM会在运行时数据区创建一个虚拟机栈。 在栈中 运行方法 每运行一个方法 ,会压入一个栈帧

    image.png

    • 虚拟机栈大小限制 Xss参数指定

    image.png

    -Xsssize
    设置线程堆栈大小(以字节为单位)。k或k表示KB, m或m表示MB, g或g表示GB。默认值取决于虚拟内存。
    下面的示例以不同的单位将线程堆栈大小设置为1024kb:
    -Xss1m
    -Xss1024k
    -Xss1048576
    这个选项相当于-XX:ThreadStackSize。

    栈帧

    栈帧内主要包含

    • 局部变量表
    • 操作数栈
    • 动态连接
    • 完成出口

    栈帧对内存区域的影响

    以以下代码为例

    public class Apple {
    public int grow() throws Exception {
    int x = 1;
    int y = 2;
    int z = (x + y) * 10;
    return z;
    }
    public static void main(String[] args) throws Exception {
    Apple apple = new Apple();
    apple.grow();
    apple.hashCode();
    }
    }

    因为JVM识别的.class文件,而不是.java文件。因此,我们需要拿到其字节码,可以通过ASM plugin插件 右键获取 或者通过 javap -v xxx.class 获取 (本文通过javap 方式获取) 其字节码如下

    Classfile /XXX/build/classes/java/mainXXX/Apple.class
    Last modified 2021-8-11; size 668 bytes
    MD5 checksum d10da1235fad7eba906f5455db2c5d8b
    Compiled from "Apple.java"
    public class Apple
    minor version: 0
    major version: 51
    flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
    #1 = Methodref #6.#29 // java/lang/Object."<init>":()V
    #2 = Class #30 // Apple
    #3 = Methodref #2.#29 // Apple."<init>":()V
    #4 = Methodref #2.#31 // Apple.grow:()I
    #5 = Methodref #6.#32 // java/lang/Object.hashCode:()I
    #6 = Class #33 // java/lang/Object
    #7 = Utf8 <init>
    #8 = Utf8 ()V
    #9 = Utf8 Code
    #10 = Utf8 LineNumberTable
    #11 = Utf8 LocalVariableTable
    #12 = Utf8 this
    #13 = Utf8 Apple;
    #14 = Utf8 grow
    #15 = Utf8 ()I
    #16 = Utf8 x
    #17 = Utf8 I
    #18 = Utf8 y
    #19 = Utf8 z
    #20 = Utf8 Exceptions
    #21 = Class #34 // java/lang/Exception
    #22 = Utf8 main
    #23 = Utf8 ([Ljava/lang/String;)V
    #24 = Utf8 args
    #25 = Utf8 [Ljava/lang/String;
    #26 = Utf8 apple
    #27 = Utf8 SourceFile
    #28 = Utf8 Apple.java
    #29 = NameAndType #7:#8 // "<init>":()V
    #30 = Utf8 Apple
    #31 = NameAndType #14:#15 // grow:()I
    #32 = NameAndType #35:#15 // hashCode:()I
    #33 = Utf8 java/lang/Object
    #34 = Utf8 java/lang/Exception
    #35 = Utf8 hashCode
    {
    public Apple();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    LineNumberTable:
    line 3: 0
    LocalVariableTable:
    Start Length Slot Name Signature
    0 5 0 this Apple;

    public int grow() throws java.lang.Exception;
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
    stack=2, locals=4, args_size=1
    0: iconst_1
    1: istore_1
    2: iconst_2
    3: istore_2
    4: iload_1
    5: iload_2
    6: iadd
    7: bipush 10
    9: imul
    10: istore_3
    11: iload_3
    12: ireturn
    LineNumberTable:
    line 5: 0
    line 6: 2
    line 7: 4
    line 8: 11
    LocalVariableTable:
    Start Length Slot Name Signature
    0 13 0 this Apple;
    2 11 1 x I
    4 9 2 y I
    11 2 3 z I
    Exceptions:
    throws java.lang.Exception

    public static void main(java.lang.String[]) throws java.lang.Exception;
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
    stack=2, locals=2, args_size=1
    0: new #2 // class Apple
    3: dup
    4: invokespecial #3 // Method "<init>":()V
    7: astore_1
    8: aload_1
    9: invokevirtual #4 // Method grow:()I
    12: pop
    13: aload_1
    14: invokevirtual #5 // Method java/lang/Object.hashCode:()I
    17: pop
    18: return
    LineNumberTable:
    line 11: 0
    line 12: 8
    line 13: 13
    line 14: 18
    LocalVariableTable:
    Start Length Slot Name Signature
    0 19 0 args [Ljava/lang/String;
    8 11 1 apple Lcom/enjoy/ann/Apple;
    Exceptions:
    throws java.lang.Exception
    }
    SourceFile: "Apple.java"

    从其字节码中 我们可以看到这么一段

      public Apple();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #1 // Method java/lang/Object."<init>":()V
    4: return
    LineNumberTable:
    line 3: 0
    LocalVariableTable:
    Start Length Slot Name Signature
    0 5 0 this Apple;

    这是Apple的构造方法,虽然我们没有写,但是默认有无参构造方法实现

    回到正文 下面我们对grow()方法做解析

     public int grow() throws java.lang.Exception;
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
    stack=2, locals=4, args_size=1
    0: iconst_1
    1: istore_1
    2: iconst_2
    3: istore_2
    4: iload_1
    5: iload_2
    6: iadd
    7: bipush 10
    9: imul
    10: istore_3
    11: iload_3
    12: ireturn
    LineNumberTable:
    line 5: 0
    line 6: 2
    line 7: 4
    line 8: 11
    LocalVariableTable:
    Start Length Slot Name Signature
    0 13 0 this Apple;
    2 11 1 x I
    4 9 2 y I
    11 2 3 z I
    Exceptions:
    throws java.lang.Exception

    我们可以看到其code代码区域,有 0 1 2 3 既

    image.png

    这是grow栈帧中的字节码地址(相对于改方法的偏移量)表,当程序运行的时候,程序计数器中的数会被调换为运行这个方法的字节码的行号 0 1 2 3 [字节码行号] 而字节码的行号 对应JVM 字节码指令助记符 下面对字节码地址表中涉及的字节码行号 进行理解

             0: iconst_1
    1: istore_1
    2: iconst_2
    3: istore_2
    4: iload_1
    5: iload_2
    6: iadd
    7: bipush 10
    9: imul
    10: istore_3
    11: iload_3
    12: ireturn

    首先 进入grow方法 记录进入方法时所在main()中的行号 作为完成出口,如main方法中grow方法字节码地址为3 方法完成后,接着执行完成出口的下一行字节码地址,所有的操作都在操作数栈中完成

    进入grow()栈帧中。程序计数器将计数器置为0,如果该类是静态方法,则局部变量表不变,如果该类不是静态方法,则在局部变量量中加入该对象实例this。类似下图

    image.png

    • 0: iconst_1
      • i 表示int const 表示常量 后面的1 表示值 ,这里表示创建int常量 1 ,放入操作数栈。

    image.png 然后code代码运行下一行

    • 1: istore_1
      • 这里将程序计数器count值改为1,然后 i 表示 int ,store表示 存储, 1 表示存储下标 存储到局部变量表中1的位置 ,我们这里将操作数中值为1的int出栈放到局部变量中 存储。

    image.png

    上面两条字节码 对应 int X = 1

    i_const_1 对应右边 定义1
    i_store_1 对应左边 用一个变量X存储 1 int y = 2参考上面分析

    下面我们来看看 int z = (x + y) * 10; x 和 y都在本地布局变量中有存储,因此,执行这条代码的时候,我们不需要上述步骤了,我们可以通过4: iload_1,将布局变量中1位置的数据加载到操作数栈中

    image.png

    下面执行code中 6: iadd,将操作数栈中的数据弹出两个操作数,再将结果存入栈顶,这个时候结果仅仅保留在操作数栈

    image.png 这个时候我们已经完成了 (X + y)这步 ,接下来看 * 10这步,这个时候我们跳到 7: bipush 10这个值也是常量值,但是比较大 操作指令有点不一样,0-5用const,其它值JVM采用bipush指令将常量压入栈中。 10对应要压入的值 。

    image.png 然后我们跳到下一行 9: imul .这是一个加法操作指令。我们可以看到操作号直接从7变成了9.这是因为bipush指令过大,占用了2个操作指令长度。

    image.png 这个时候我们已经得到了计算结果,还需要将其赋值局部变量z进行变量存储.

    image.png 此时,我们已经完成了 z = (x + y) * 10的操作了。 此时执行最后一行 return z;首先 ,取出z,将其load进操作数栈,然后利用ireturn返回结果。该方法结束。这个时候,完成出口存储的上一方法中的程序计数器的值,回到上一方法中正确的位置。

    补充

    0 1 2 3 4 7 9 字节码偏移量 针对本方法的偏移

    程序计数器只存储自己这个方法的值
    动态连接 确保多线程执行程序的正确性

    本地方法栈

    本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为JVM执行 Java 方法(也就是字节码)服务,而本地方法栈则是为JVM使用到的 Native方法服务。在hotSpot中,本地方法栈与虚拟机栈是一体

    在本地方法栈中,程序计数器为null,因为,本地方法栈中运行的形式不是字节码


    线程共享区

    下面还是来一段代码

    public class Learn {
    static int NUMBER = 18; //静态变量 基本数据类型
    static final int SEX = 1; //常量 基本数据类型
    static final Learn LERARN = new Learn(); //成员变量 指向 对象
    private boolean isYou = true; //成员变量


    public static void main(String[] args) {
    int x = 18;//局部变量
    long y = 1;
    Learn learn = new Learn(); //局部变量 对象
    learn.isYou = false;//局部变量 改变值
    learn.hashCode(); //局部变量调用native 方法
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(128 * 1024 * 1024);//分配128M直接内存
    }
    }

    类加载过程中

    Learn 加载到方法区

    类中的 静态变量、常量加载到方法区。

    方法区

    是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,

    我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。 随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。

    创建的时候,到底是在堆上分配,还是在栈上分配呢?

    这和两个方面有关:对象和在 Java 类中存在的位置。 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。 image.png

    JVM内存处理

    先来一段代码进行后续分析

    public class JVMObject {
    public final static String MAN_TYPE = "man"; // 常量
    public static String WOMAN_TYPE = "woman"; // 静态变量
    public static void main(String[] args)throws Exception {
    Teacher T1 = new Teacher();
    T1.setName("A");
    T1.setSexType(MAN_TYPE);
    T1.setAge(36);
    for(int i =0 ;i < 15 ;i++){
    //每触发一次gc(),age+1 记录age的字段是4位 最大1111 对应15
    System.gc();//主动触发GC 垃圾回收 15次--- T1存活 T1要进入老年代
    }
    Teacher T2 = new Teacher();
    T2.setName("B");
    T2.setSexType(MAN_TYPE);
    T2.setAge(18);
    Thread.sleep(Integer.MAX_VALUE);//线程休眠 后续进行观察 T2还是在新生代
    }
    }

    class Teacher{
    String name;
    String sexType;
    int age;

    public String getName() {
    return name;
    }
    public void setName(String name) {
    this.name = name;
    }

    public String getSexType() {
    return sexType;
    }
    public void setSexType(String sexType) {
    this.sexType = sexType;
    }
    public int getAge() {
    return age;
    }
    public void setAge(int age) {
    this.age = age;
    }
    }

    1. JVM申请内存
    2. 初始化运行时数据区
    3. 类加载

    image.png

    • 执行方法(加载后运行main方法)

    image.png 4.创建对象

    image.png

    流程

    JVM 启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,堆中的对象最后通过垃圾回收处理。

    • 堆空间分代划分

    image.png

    通过HSDB查看堆空间划分 及内存分配

    • 先运行相关类

    • CMD命令行 运行jps 查看相关进程

      • image.png
    • 找到JDK安装目录 java8u292\bin bin 目录下点击HSDB.exe运行程序

    • 通过File下 点击 下图 进行 进程绑定

      • image.png
      • 将之前通过jps获取的进程号输入 输入框
      • image.png
      • 绑定后界面为 该进程下进程信息
      • image.png

    • 通过 Tools栏下的heap parameter 可以观察堆分配情况
      • image.png
      • 我们可以看到堆分区的划分和之前的是类似的,这样可以直观的看到堆的地址,也可以让我们对JVM将内存虚拟化有更直观的认知。
      • image.png

    • 对象的地址分配
      • 我们也可以通过object histogram查看对象的分配情况

      • image.png

      • 进入后界面如下所示

      • image.png

      • 我们可以通过全类名搜索相关类

      • image.png

      • 找到自己想要的查看的类后,可以看到 第一行表示这个类所有对象的size ,count 数量是多少个。比如标红的表示,Teacher类所有对象占用48,一共两个对象。双击这一栏,进入详细页面

      • image.png

      • 点击对应条目,点击下方insperctor查看详细信息,将其与之前堆内存地址分配对比,发现一个主动调用gc()从新生代慢慢进入老年代,这个A已经进入老年代了,而另一个B还在新生代。

      • image.png

    通过HSDB查看栈

    可以在HSDB绑定进程时,查看所有列出的线程信息,点击想要查看的线程,如main线程。点击浮窗菜单栏上的第二个 我们可以查看主线程的栈内存情况 ,如下图所示。

    image.png

    有兴趣的朋友可以去玩玩 这个工具

    内存溢出

    栈溢出 StackOverflowError

    方法调用方法 递归

    堆溢出

    OOM 申请分配内存空间 超出堆最大内存空间

    可以通过设置运行设置 进行模拟

    image.png

    image.png 可以通过设置 VM options进行设置JVM 相关参数配置参考相关链接第一个 可以通过 调大 -Xms,-Xmx参数避免栈溢出

    方法区溢出

    (1) 运行时常量池溢出

    (2)方法区中保存的Class对象没有被及时回收掉或者Class信息占用的内存超过了我们配置。

    class回收条件
    • 该类所有的实例都已经被回收,堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

    直接内存溢出

    直接内存的容量可以通过MaxDirectMemorySize来设置(默认与堆内存最大值一样)


    收起阅读 »

    Jetpack Compose 快来学学吧!

    简介官方入门文档:developer.android.com/jetpack/com…Jetpack Compose 是 Google 在2019年 Google I/O 大会上公布的全新的 Android 原生 UI 开发框架,历时两年2021年7月29日,...
    继续阅读 »

    简介

    官方入门文档:developer.android.com/jetpack/com…

    Jetpack Compose 是 Google 在2019年 Google I/O 大会上公布的全新的 Android 原生 UI 开发框架,历时两年2021年7月29日,正式版终于问世。官方介绍有以下特点:

    • 更少的代码

      • 使用更少的代码实现更多的功能,并且可以避免各种错误,从而使代码简洁且易于维护。
    • 直观的 Kotlin API

      • 只需描述界面,Compose 会负责处理剩余的工作。应用状态变化时,界面会自动更新。
    • 加快应用开发

      • 兼容现有的所有代码,方便随时随地采用。借助实时预览和全面的 Android Studio 支持,实现快速迭代。
    • 功能强大

      • 凭借对 Android 平台 API 的直接访问和对于 Material Design、深色主题、动画等的内置支持,创建精美的应用。

    如何理解“全新 UI 框架”?全新在于它直接抛弃了我们写了 N 年的 View 和 ViewGroup 那一套东西,从上到下撸了一整套全新的 UI 框架。就是说,它的渲染机制、布局机制、触摸算法以及 UI 的具体写法,全都是新的。

    个人总结 Compose 特点有三个:代码写 UI、声明式 UI、全新 UI框架。

    Motivation

    参考自:深入详解 Jetpack Compose | 优化 UI 构建

    解耦

    目前 Android 写界面的方式,布局文件写在 layout.xml 中,而视图模型(也就是代码逻辑部分)写在 ViewModel 或者 Presenter 中,通过某些 API (例如 findViewById) 建立两者之间的联系。这两者之间耦合十分紧密,例如在 XML 中修改了 id 或者 View 的类型,需要在视图模型中修改对应代码;此外如果动态删除或增加了某个 View,布局 XML 不会更新,因此需要在视图模型手动维护。

    造成上述现象的原因是因为 XML 布局和视图模型就应该是一体的。那能不能直接用代码写布局文件呢?当然是可以的,但肯定不是现在这样 new 一个 ViewGroup,然后 addView 的方式。比较容易想到的是通过 kotlin DSL,需要注意由于在不同情况下显示的 UI 可能不同,所以 DSL 一定含有逻辑。

    历史包袱

    Android已经十年多了,传统的Android UI 有很多历史遗留问题,而有些官方也很难修改。比如View.java有三万多行代码,ListView 已经废弃了。

    为了避免重蹈覆辙,在 Jetpack Compose 的世界中使用函数替代了类型,用组合替代继承,抛弃原有Android View System,操作canvas直接进行绘制:

    重点目标是解决耦合问题,解决基础类 View.java 爆炸问题。基于组合优于继承的思想,重新设计一套解偶的UI框架。

    快速入门

    环境准备

    参考自官方文档:developer.android.com/jetpack/com…

    1. 下载 Android Studio Arctic Fox:developer.android.com/studio (注意一定要使用2021.7.29日后发布的AS)
    2. 加入依赖
    android {
    buildFeatures {
    compose true
    }
    // compose_version = '1.0.0'
    composeOptions {
    kotlinCompilerExtensionVersion compose_version
    kotlinCompilerVersion '1.5.10'
    }
    }

    dependencies {
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    implementation 'androidx.activity:activity-compose:1.3.0'
    }
    1. 在代码中使用 Compose

    Activity

    class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    HelloWorld()
    }
    }
    }

    Fragment

    class MainFragment: Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
    return ComposeView(requireContext()).apply {
    setContent {
    HelloWorld()
    }
    }
    }
    }

    XML

    <androidx.compose.ui.platform.ComposeView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/compose"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
    findViewById<ComposeView>(R.id.compose).setContent {
    HelloWorld()
    }

    小试牛刀

    我第一次写 Compose ,复刻大力辅导我的页面大约花费 2.5 小时(仅 UI,不含逻辑)。实际感受学习成本不高,如果大家有 flutter 基础,可以说非常容易上手。

    设计稿Compose 还原图

    展示一下头部帐户信息区域代码:

    @Composable
    fun AccountArea() {
    Row(
    verticalAlignment = Alignment.CenterVertically,
    modifier = Modifier
    .height(64.dp)
    .fillMaxWidth()
    ) {
    Image(
    painter = painterResource(id = R.drawable.avatar),
    contentDescription = "我的头像",
    modifier = Modifier
    .size(64.dp)
    .clip(shape = CircleShape)
    )

    Spacer(modifier = Modifier.width(12.dp))
    Column(
    modifier = Modifier.align(Alignment.CenterVertically)
    ) {
    Text(text = "华Lena爱生活", style = MyTabTheme.typography.h2, color = MyTabTheme.colors.textPrimary)
    Spacer(modifier = Modifier.height(10.dp))
    Row {
    GradeOrIdentity(text = "选择年级")
    Spacer(modifier = Modifier.width(12.dp))
    GradeOrIdentity(text = "选择身份")
    }

    }

    Spacer(modifier = Modifier.weight(1f))
    Image(
    painter = painterResource(id = R.drawable.icon_header_more),
    contentDescription = "进入个人信息设置",
    modifier = Modifier
    .size(16.dp)
    )
    }
    }


    @Composable
    fun GradeOrIdentity(text: String) {
    Row(
    horizontalArrangement = Arrangement.Center,
    verticalAlignment = Alignment.CenterVertically,
    modifier = Modifier
    .size(74.dp, 22.dp)
    .clip(MyTabTheme.shapes.medium)
    .background(MyTabTheme.colors.background)
    ) {
    Text(text = text, style = MyTabTheme.typography.caption, color = MyTabTheme.colors.textSecondary)
    Spacer(modifier = Modifier.width(2.dp))
    Image(
    painter = painterResource(id = R.drawable.icon_small_more),
    contentDescription = text,
    modifier = Modifier
    .size(8.dp)
    )
    }
    }

    大致结构是通过组合 Row 和 Column,再加上 Text、Image、Spacer 等一些“控件”,和 Flutter 很像。Flutter 中万物皆 Widget,Column、ListView、GestureDetector、Padding 都是 Widget,Compose 呢?

    @Composable 函数

    可能直观上大家会觉得 Row/Text/Image 是某种 “View”?然而并不是,其实它们都是函数,唯一特殊的是带有了 @Composable 注解。该函数有个规则:@Composeable 函数必须在另一个 @Composeable 函数中被调用(和协程关键字 suspend 类似),我们自己写的函数也需要加上该注解。所以有种说法是,Compose 中一切皆函数,开发者通过组合 @Composeable 函数达到想要的效果。

    // Composable
    fun Example(a: () -> Unit, b: @Composable () -> Unit) {
    a() // 允许
    b() // 不允许
    }

    @Composable
    fun Example(a: () -> Unit, b: @Composable () -> Unit) {
    a() // 允许
    b() // 允许
    }

    Modifier

    现有 Android View 体系中设置宽高、Margin、点击事件、背景色是通过设置 View 的某个属性来实现的。而 Flutter 通过包裹一层Widget实现(例如 Padding,SizedBox,GestureDetector、DecoratedBox)。刚刚我们说过,Compose 中一切皆函数,那 Compose 是选择为每个 @Composable 函数提供 height/width/margin 等参数,还是再包一层 @Composable 函数呢?答案是前者,Compose 把它们统一抽象成了 modifier 参数,通过串联 Modifier 设置大小、行为、外观。Modifier 功能很强大,除了设置宽高、点击、Padding 、背景色之外,还能设置宽高比、滚动、weight,甚至 draw、layout、拖拽、缩放都能做。

    (大家如果熟悉 Flutter 应该知道,Flutter 为人诟病的一点是它的 Widget 层级极深,很难方便得找到想要的 Widget。而在 Compose 中不会有此问题,因为 Modifier 的设计,是的它可以做到和现有 XML 布局的层级是一样的。)

    开发调试

    现有 XML 布局的预览功能是很强大的,开发时能快速看到效果,Compose 作为 Google 下一代 UI 框架在这点上的支持也相当强大。(这里吐槽一下 Flutter,必须编译到手机上才能看到效果)。加上 @Preview 注解可实现预览,如下图。点击预览图上的控件也可以直接定位到代码。

    除了静态预览之外,Compose 还支持简单的点击交互和调试动画。能实现这些的原因是 Compose 确确实实编译出了可执行的代码。(有些跨平台的影子)

     

    常用 Composable

    这里做个总结,大部分 Android 已有的能力使用 Compose 都能实现。

    AndroidComposeFlutter说明
    TextViewTextText
    EditTextTextFieldTextField
    ImageViewImageImage如果是加载网络图片,Android/Compose 需使用三方库
    LinearLayoutColumn/RowColumn/Row
    ListView/RecyclerViewLazyColumn/LazyRowListView
    GridView/RecyclerViewLazyVerticalGrid(实验性)GridView
    ConstraintLayoutConstraintLayout
    FrameLayoutBoxStack
    ScrollViewModifier.verticalScroll()SingleChildScrollView
    NestedScrollViewModifier.nestedScroll()NestedScrollViewCompose 通过 modifier 实现
    ViewPagerPageViewCompose 有开源方案:google.github.io/accompanist…
    padding/marginModifier.padding()Padding
    layout_height/layout_widthModifier.size()/height()/width()/fillMaxWidth()SizedBox
    backgroundModifier.drawbehind{}DecoratedBox

    Text

    详见:Compose Text

    @Composable
    fun BoldText() {
    Text("Hello World", color = Color.Blue,
    fontSize = 30.sp, fontWeight = FontWeight.Bold,
    modifier = Modifier.padding(10.dp)
    )
    }

    TextField

    @Composable
    fun StyledTextField() {
    var value by remember { mutableStateOf("Hello\nWorld\nInvisible") }
    TextField(
    value = value,
    onValueChange = { value = it },
    label = { Text("Enter text") },
    maxLines = 2,
    textStyle = TextStyle(color = Color.Blue, fontWeight = FontWeight.Bold),
    modifier = Modifier.padding(20.dp)
    )
    }

    Image

    Image(
    painter = painterResource(R.drawable.header),
    contentDescription = null,
    modifier = Modifier
    .height(180.dp)
    .fillMaxWidth(),
    contentScale = ContentScale.Crop
    )

    Column / Row

    Column 表示纵向排列,Row 表示横行排列。以 Column 为例代码如下:

    Column(modifier = Modifier.background(Color.LightGray).height(400.dp).width(200.dp)) {
    Text(text = "FIRST LINE", fontSize = 26.sp)
    Divider()
    Text(text = "SECOND LINE", fontSize = 26.sp)
    Divider()
    Text(text = "THIRD LINE", fontSize = 26.sp)
    }

    Box

    Box(Modifier.background(Color.Yellow).size(width = 150.dp, height = 70.dp)) {
    Text(
    "Modifier sample",
    Modifier.offset(x = 25.dp, y = 30.dp)
    )
    Text(
    "Layout offset",
    Modifier.offset(x = 0.dp, y = 0.dp)
    )
    }

    LazyColumn / LazyRow

    LazyColumn(modifier = modifier) {
    items(items = names) { name ->
    Greeting(name = name)
    Divider(color = Color.Black)
    }
    }

    ConstraintLayout

    详细介绍可参考:medium.com/android-dev…

    需要额外引入依赖:

    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta01"
    @Composable
    fun ConstraintLayoutContent() {
    ConstraintLayout {
    val (button1, button2, text) = createRefs()
    Button(
    onClick = { /* Do something */ } ,
    modifier = Modifier.constrainAs(button1) {
    top.linkTo(parent.top, margin = 16.dp)
    }
    ) {
    Text("Button 1")
    }

    Text("Text", Modifier.constrainAs(text) {
    top.linkTo(button1.bottom, margin = 16.dp)
    centerAround(button1.end)
    } )

    val barrier = createEndBarrier(button1, text)
    Button(
    onClick = { /* Do something */ } ,
    modifier = Modifier.constrainAs(button2) {
    top.linkTo(parent.top, margin = 16.dp)
    start.linkTo(barrier)
    }) {
    Text("Button 2")
    }
    }
    }

    在声明式 UI 中是无法获取一个 “View” 的 id,但是在 ConstraintLayout 中似乎有所例外,因为需要 id 来描述相对位置。

    Scroll(滚动)

    详见:developer.android.com/jetpack/com…

    在 Compose 中只需加入 Modifier.scroll() / Modifier.verticalScroll() / Modifier.horizontalScroll() 即可实现。

    Column(     
    modifier = Modifier
    .background(Color.LightGray)
    .size(100.dp)
    .verticalScroll(rememberScrollState())
    ) {
    repeat(10) {
    Text("Item $it", modifier = Modifier.padding(2.dp))
    }
    }

    NestedScroll

    详见:developer.android.com/jetpack/com…

    简单的嵌套滚动只需要加上了 Modifier.scroll() 即可实现

    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
    Box(
    modifier = Modifier
    .background(Color.LightGray)
    .verticalScroll(rememberScrollState())
    .padding(32.dp)
    ) {
    Column {
    repeat(6) {
    Box(
    modifier = Modifier
    .height(128.dp)
    .verticalScroll(rememberScrollState())
    ) {
    Text(
    "Scroll here",
    modifier = Modifier
    .border(12.dp, Color.DarkGray)
    .background(brush = gradient)
    .padding(24.dp)
    .height(150.dp)
    )
    }
    }
    }
    }

    如果是复杂的嵌套滚动需要使用 Modifier.nestedScroll()

    val toolbarHeight = 48.dp
    val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
    val nestedScrollConnection = remember {
    object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
    val delta = available.y
    val newOffset = toolbarOffsetHeightPx.value + delta
    toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
    return Offset.Zero
    }
    }
    }

    Box(
    Modifier
    .fillMaxSize()
    .nestedScroll(nestedScrollConnection)
    ) {
    // our list with build in nested scroll support that will notify us about its scroll
    LazyColumn(contentPadding = PaddingValues(top = toolbarHeight)) {
    items(100) { index ->
    Text("I'm item $index", modifier = Modifier
    .fillMaxWidth()
    .padding(16.dp))
    }
    }

    TopAppBar(
    modifier = Modifier
    .height(toolbarHeight)
    .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) } ,
    title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") }
    )
    }

    声明式 UI

    假设要修改一个 View 的颜色,如果使用命令式的开发方式首先需要借助 findViewById 等获得此 View 的句柄,然后通过调用其方法实现UI的变化。

    // Imperative style
    View b = findViewById(...)
    b.setColor(red)

    命令式UI需要持有全功能UI句柄,然后通过调用相关方法对UI进行变更

    如果使用声明式的方式完成同样效果,只需要在声明 View 的同时将 state 关联到对应的 UI 属性,然后通过 state 的变化,驱动 View 的重新绘制

    // Declarative style
    View {
    color: state.color,
    }

    声明式UI仅仅描述当前的UI状态,当状态变化时框架自动更新UI

    声明式UI中无法通过获取View的句柄对其属性进行修改,这间接保证了UI属性的不可变性(immutable),state作为唯一的“Source of Truth”,任何时刻保持与UI状态的绝对一致。因此声明式UI不可绕开的是状态管理。

    状态管理

    参考:developer.android.com/jetpack/com…

    如果使用过声明式框架,应该对状态管理并不陌生。Flutter 中有 StatelessWidget 和 StatefulWidget,StatefulWidget 用于管理状态。而 Compose 使用 State 管理状态(大部分情况还需要结合 remember())。在下面的例子中,每当 text 值变化时,会触发重组,更新UI。

    重组:在数据发生变化时重新运行某些函数以达到改变 UI

    为什么修改 text 就会触发更新 UI 呢?这是 Compose 赋予 State 的能力(mutableStateOf()返回的是一个可变的 State),State 表示该变量是 Compose 需要关心的“状态”,当状态变化后需要被感知并触发重组。实际使用中,State 一般会配合 remember 函数使用,remember 是指当重组发生时可以记住上一次的 State。在下面例子中,当 Foo() 函数由于重组而被重新执行时,若没有 remember 该函数的局部变量 text 会被赋值成初始值(即空字符串),这显然与我们的预期不符,因此诞生了 remember。

    @Composable
    fun Foo() {
    var text: String by remember { mutableStateOf("") }
    Button(onClick = { text = "$text\n$text" }) {
    Text(text)
    }
    }

    上面的代码和 Flutter 中的 MobX 很像,但是相比于 MobX,Compose 的重组范围细粒度更精准,它会自动分析出最小的重组范围。例如上述代码只要重组的范围是 Button 的 lambda 部分

    推荐架构

    这些 State 应该放在哪里好呢?是分布在各个 @Composable 函数中吗?官方提供了一种推荐的架构:用 ViewModel 持有 LiveData,UI 界面观察 LiveData。

    事件从下往上,状态从上往下(单向数据流)

    其他

    作为一个 UI 框架,还需要包括主题(Theme)、动画(Animation),这部分大家自行参考官方文档

    实际使用呢?

    实际使用 Compose 可能会关心这些数据

    包大小

    参考自:Jetpack compose before and after

    结论:包大小有显著缩小

    包大小
    方法数
    编译速度

    性能

    理论上由于没有了 XML -> Java 的转换所以对复杂布局有优势。

    实际使用中,没有找到比较权威的数据,官方给的 Demo 刷着挺流畅的。

    但是在 GitHub 上会也看到很多 issue 吐槽 Compose 的性能:github.com/android/com…

    上手门槛&开发效率

    优势:更容易写动画、自定义View、预览功能很强大。

    劣势:需要适应声明式写法

    android-developers.googleblog.com/2021/05/mer… 该文章声称 Compose 提高了 56% 的开发效率。如果有 Flutter 基础可以更快上手,大家可以尝试用 Compose 实现一个界面,用起来会挺顺。

    经典 View 中写动画、自定义View、自定义 ViewGroup 都有一定门槛,但在 Compose 中使用系统提供的API,例如 animateXXXAsState、Canvas、Layout 实现相同功能会简单很多。

    现有库的兼容性

    实际使用时可能会遇到 Compose 基建不完善的情况。当然,很多三方库已经有了 Compose 的兼容版本,例如 Glide、ViewPager、SwipeRefresh 、RxJava等,但不得不承认更多库是没有 Compose 版本的,例如 Fresco、TTVideoPlayer、WebView、CoordinatorLayout 等都没有。官方也提供了一种解决方法 AndroidView,写法如下:

    @Composable
    fun CustomView() {
    val selectedItem = remember { mutableStateOf(0) }
    // Adds view to Compose
    AndroidView(
    modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
    factory = { context ->
    // Creates custom view
    CustomView(context).apply {
    // Sets up listeners for View -> Compose communication
    myView.setOnClickListener {
    selectedItem.value = 1
    }
    }
    } ,
    update = { view ->
    // View's been inflated or state read in this block has been updated
    // Add logic here if necessary
    view.coordinator.selectedItem = selectedItem.value
    }
    )
    }

    我尝试把 QrCodeView(一个二维码扫描 View) 用上述方式包装成 Compose ,遇到了一些问题。

    1. 混写命令式和声明式,很不舒服

    2. 不熟悉 Compose 导致的代码错误

      1. 例如:生命周期问题,composable 函数会在传统 View.onAttachToWindow() 时才会执行,所以在 Activity.onCreate/onStart/onResume 想要对此 AndroidView 做一些操作需要一些技巧。很多时候需要翻阅官方提供的 Sample 才能找到答案。
    3. 其他奇奇怪怪没有考虑 Compose 时的问题。很多三方库会有各种和 Compose 不兼容问题,需要一点点踩坑。

    如果是一个无 Compose 版本的 ViewGroup,例如瀑布流布局,就别想着迁移了,还是用 Compose 重写个相同功能的 Composable 函数比较现实,Compose 提供了 Layout() 函数对自定义布局会比较简单的。

    是否可以跨平台?

    根据 Kotlin 跨平台特性,Compose 其实是有跨平台潜力的,官方也给了 Demo,当然都处于非常原始的状态。

    Compose For Desktop:Compose for Desktop UI Framework

    Compose For Web:Technology Preview: Jetpack Compose for Web | The Kotlin Blog


    收起阅读 »

    Android Studio中的 Image Asset Studio(图标生成工具)

    Android 图标在线生成Android Studio 包含一个名为 Image Asset Studio 的工具,它可以帮我们把自定义图像、系统图标素材、文本字符串自动生成适配系统的应用图标。它为你的应用程序支持的每个像素密度生成一组适当分辨率的图标。Im...
    继续阅读 »

    Android 图标在线生成

    Android Studio 包含一个名为 Image Asset Studio 的工具,它可以帮我们把自定义图像系统图标素材文本字符串自动生成适配系统的应用图标。它为你的应用程序支持的每个像素密度生成一组适当分辨率的图标。Image Asset Studio 将新生成的图标放置res/在项目目录下的特定文件夹中(例如 mipmap/ 或 drawable/)。在运行时,Android 根据运行应用的设备的屏幕密度使用适当的资源。

    Image Asset Studio 可帮助您生成以下图标类型:

    • 启动图标(Launcher icons)

      • Launcher Icons(Adaptive and Legacy):AS 3.0后新增,用于自适应启动图标,兼容新旧版系统;
      • Launcher Icons(Legacy only):用于非自适应的启动图标,仅限旧版系统(Android 8.0之前);
    • 操作栏和选项卡图标(Action bar and tab icons)

    • 通知图标(Notification icons)

    • TV Banners

    • TV Channel lcons

    Image Asset 是什么

    Image Asset Studio 可帮助您创建不同密度的各种类型的图标,并准确显示它们在项目中的放置位置。以下部分描述了您可以创建的图标类型以及您可以使用的图像和文本输入。

    Launcher icons

    Image Asset Studio 将启动图标放置在目录中的适当位置res/mipmap-density/。它还创建了适合 Google Play 商店的 512 x 512 像素图像。

    Action bar and tab icons

    Image Asset Studio 将图标放置在res/drawable-density/目录中的适当位置 。

    我们建议操作栏和选项卡图标使用 Material Design 风格。作为 Image Asset Studio 的替代方案,您可以使用 Vector Asset Studio创建操作栏和选项卡图标。矢量绘图适用于简单的图标,可以减少应用程序的大小。

    Notification icons

    通知是您可以在应用程序的正常 UI 之外向用户显示的消息。Image Asset Studio 将通知图标放置在目录中的适当位置 :res/drawable-density/

    • Android 2.2(API 级别 8)及更低版本的图标放置在目录中。res/drawable-density/
    • Android 2.3 到 2.3.7(API 级别 9 到 10)的图标放置在 目录中。res/drawable-density-v9/
    • Android 3(API 级别 11)及更高版本的图标放置在目录中。res/drawable-density-v11/
    • 如果你的应用程序支持 Android 2.3 到 2.3.7(API 级别 9 到 10),Image Asset Studio 会生成一个灰色版本的图标。后来的 Android 版本使用 Image Asset Studio 生成的白色图标。

    Clip Art(剪贴画)

    Image Asset Studio 使您可以轻松导入 VectorDrawable 和 PNG 格式的 Google Material 图标:只需从对话框中选择一个图标即可。

    Images(图片)

    您可以导入自己的图像并根据图标类型对其进行调整。Image Asset Studio 支持以下文件类型:PNG(首选)、JPG(可接受)和 GIF(不可用)。

    Text(文本)

    Image Asset Studio 允许您以各种字体键入文本字符串,并将其放置在图标上。它将基于文本的图标转换为不同密度的 PNG 文件。你可以使用计算机上安装的字体。

    使用 Image Asset Studio

    要启动 Image Asset Studio,请按照下列步骤操作:

    • 在Project窗口中,选择 Android view。
    • 右键单击res文件夹并选择 New > Image Asset。

    • Image Asset Studio 中的自适应和旧式图标向导。

    继续执行以下步骤:

    • 如果您的应用支持 Android 8.0及以上,请创建自适应和旧版启动器图标
    • 如果您的应用支持不高于 Android 7.1 的版本,请创建旧版启动器图标
    • 创建操作栏或选项卡图标。
    • 创建通知图标。

    创建Launcher Icons(Adaptive and Legacy)

    打开Image Asset Studio,你可以通过以下步骤添加图标:

    • Icon Type 中, 选择Launcher Icons (Adaptive and Legacy)

    • Foreground Layer选项卡中,选择Asset Type,然后在下方的字段中指定asset

      • 选择Image以指定图像文件的路径。
      • 选择Clip Art 以从Material Design 图标集中指定一个图像 。
      • 选择Text以指定文本字符串并选择字体。  文章上面有各自选择的教程
    • Background Layer选项卡中,选择Asset Type,然后在下方的字段中指定Asset。你可以选择一种颜色或指定要用作背景层的image。

    • Options选项卡中,查看默认设置并确认您要生成 Legacy、Round 和 Google Play Store 图标。

    • (可选)更改每个Foreground Layer和Background Layer选项卡的名称和显示设置:

      • Name:如果不想使用默认名称,请键入新名称。如果该资源名称已存在于项目中,如向导底部的错误所示,它将被覆盖。名称只能包含小写字符、下划线和数字
      • Trim:要调整源资产中图标图形和边框之间的边距,请选择Yes。此操作去除透明空间,同时保留纵横比。要保持源资产不变,请选择No
      • Color:要更改Clip Art or Text图标的颜色,请单击该字段。在"选择颜色"对话框中,指定一种颜色,然后单击"选择"。新值出现在该字段中。
      • Resize:使用滑块指定比例因子以调整Image, Clip Art, or Text图标的大小。当您指定颜色资源类型时,background layer的此控件将被禁用。
    • 单击Next。

    • 或者,更改资源目录:选择要添加图像资产的资源源集:src/main/res、 src/debug/res、src/release/res或自定义源集。要定义新的源集,请选择 File > Project Structure > app > Build Types. 例如,您可以定义一个 Beta 源集并创建一个图标版本,在右下角包含文本“BETA”。有关更多信息,

    • 单击Finish。Image Asset Studio 将图像添加到不同密度的 mipmap文件夹中。

    创建Launcher Icons(Legacy only)

    新增:

    • Scaling:要适合图标大小,请选择Crop或 Shrink to Fit。使用Crop,图像边缘可以被剪掉,而使用Shrink to Fit,则不会。如果源资产仍然不适合,您可以根据需要调整填充。
    • Shape:要在源资产后面放置背景,请选择一个形状,圆形、正方形、垂直矩形或水平矩形之一。对于透明背景,选择None。

    • Effect:如果要在正方形或矩形形状的右上角添加狗耳朵效果,请选择DogEar。否则,选择None。

    创建Action bar and tab icons

    创建Notification icons

    其他情况基本大同小异,这里就不多做介绍,浪费大家时间了。

    收起阅读 »

    Android面试题之Activity和Fragment生命周期 一次性记忆

    每当我们换工作面试之前,总是会不由自主的刷起面试题,大部分题我们反反复复不知道刷了多少遍,但是今天记住了,等下一次面试的时候又刷着相同的面试题,我就想问在座的各位,Activity的生命周期,你们到底刷过多少遍 [哭笑] 作为一名程序员 把时间浪费在重复性劳动...
    继续阅读 »

    每当我们换工作面试之前,总是会不由自主的刷起面试题,大部分题我们反反复复不知道刷了多少遍,但是今天记住了,等下一次面试的时候又刷着相同的面试题,我就想问在座的各位,Activity的生命周期,你们到底刷过多少遍 [哭笑] 作为一名程序员 把时间浪费在重复性劳动上是极其不能忍受的 因此 为了让自己省去不必要的脑力开销 我给自己总结了一份面试相关的记忆技巧,在这里分享给大家 记忆不是目的 把知识变成自己的才最关键

    前提

    需要熟悉Activity的生命周期 通过Activity的周期去对比理解和记忆Fragment生命周期

    Activity的生命周期

    假设你已经非常熟悉Activity的生命周期了,那么接下来咱们看Fragment的生命周期图

    Fragment的生命周期

    找出他和Activity的相同之处

    这部分完全和Activity一模一样 可以不用记忆它,咱们来看不同的地方

    其实这部分才是人们最容易搞混和记不住的地方 那咱们来分析一下:

    Fragment比Activity多了几个生命周期的回调方法

    • onAttach(Activity) 当Fragment与Activity发生关联的时候调用

    • onCreateView(LayoutInflater, ViewGroup, Bundle) 创建该F

    • onActivityCreated(Bundle) 当Activity的onCreated方法返回时调用

    • onDestroyView() 与onCreateView方法相对应,当该Fragment的视图被移除时调用

    • onDetach() 与onAttach方法相对应,当Fragment与Activity取消关联时调用 PS:注意:除了onCreateView,其他的所有方法如果你重写了,必须调用父类对于该方法的实现

      这些方法理解起来并不费劲 但是要完美记在脑子里 还是需要花上一番功夫的

      那咱们一个一个来 先从创建开始:

      1.首先 onAttach方法: 和Activity进行关联的时候调用 这个放在第一个 应该好理解

      2.我们知道 Activity在onCreate方法中需要调用setContentVIew()进行布局的加载,那么在Fragment中onCreateView就相当于Activity中的setContentVIew

      3.onActivityCreate是一个额外的方法 为了告诉Fragment当前Activity的创建执行情况 方便Fragment的后续操作

      先后顺序

      已知Fragment是依赖Activity而存在的 它们都有着相同的生命周期方法 那么先调用Activity的还是Fragment的呢? 这里分两种情况

      • 如果是创建 那么先创建Activity 后创建Fragment

      • 如果是销毁 那么先销毁Fragment 后销毁Activity

        网上有很多文章说Activity的onCreate方法在Fragment的onCreateView之后执行,这是不正确的 Fragment一般都是在Activity的onCreate()中创建 要么通过布局加载的方式 要么通过new创建Fragment对象的方式 如果没有Activity的onCreate 哪来的Fragment

        总结

        上面的理解好后,咱们再整理记忆一下

        一句话 Activity是老子 Fragment是小子 进门先让老子进 滚蛋先让小子滚 加载布局createView 老子完事吱一声(ActivityCreated)

        希望有帮到你

    收起阅读 »

    如何在大型代码仓库中删掉 6w 行废弃的文件和 exports?

    起因 很多项目历史悠久,其中很多 文件或是 export 出去的变量 已经不再使用,非常影响维护迭代。 举个例子来说,后端问你:“某某接口统计一下某接口是否还有使用?”你在项目里一搜,好家伙,还有好几处使用呢,结果那些定义或文件是从未被引入的,这就会误导你们去...
    继续阅读 »

    起因


    很多项目历史悠久,其中很多 文件或是 export 出去的变量 已经不再使用,非常影响维护迭代。
    举个例子来说,后端问你:“某某接口统计一下某接口是否还有使用?”你在项目里一搜,好家伙,还有好几处使用呢,结果那些定义或文件是从未被引入的,这就会误导你们去继续维护这个文件或接口,影响迭代效率。


    先从删除废弃的 exports 讲起,后文会讲删除废弃文件。


    删除 exports,有几个难点:




    1. 怎么样稳定的 找出 export 出去,但是其他文件未 import 的变量




    2. 如何确定步骤 1 中变量在 本文件内部没有用到 (作用域分析)?




    3. 如何稳定的 删除这些变量




    整体思路


    先给出整体的思路,公司内的小伙伴推荐了 pzavolinsky/ts-unused-exports 这个开源库,并且已经在项目中稳定使用了一段时间,这个库可以搞定上述第一步的诉求,也就是找出 export 出去,但是其他文件未 import 的变量。
    但下面两步依然很棘手,先给出我的结论:



    1. 如何确定步骤 1 中变量在本文件内部没有用到(作用域分析)?


    对分析出的文件调用 ESLint 的 API,no-unused-vars 这个 ESLint rule 天生就可以分析出文件内部某个变量是否使用,但默认情况下它是不支持对 export 出去的变量进行分析的,因为既然你 export 了这个变量,那其实 ESLint 就认为你这个变量会被外部使用。对于这个限制,其实只需要 fork 下来稍微改写即可。



    1. 如何稳定的删除这些变量?


    自己编写 rule fixer 删除掉分析出来的无用变量,之后就是格式化,由于 ESLint 删除代码后格式会乱掉,所以手动调用 prettier API 让代码恢复美观即可。


    接下来我会对上述每一步详细讲解。


    导出导入分析


    使用测试下来, pzavolinsky/ts-unused-exports 确实可以靠谱的分析出 未使用的 export 变量 ,但是这种分析 import、export 关系的工具,只是局限于此,不会分析 export 出去的这个变量 在代码内部是否有使用到


    文件内部使用分析


    第二步的问题比较复杂,这里最终选用 ESLint 配合自己 fork 改写 no-unused-vars 这个 rule ,并且自己提供规则对应的修复方案 fixer 来实现。


    为什么是 ESLint?




    1. 社区广泛使用,经过无数项目验证。




    2. 基于 作用域分析 ,准确的找出未使用的变量。




    3. 提供的 AST 符合 estree/estree 的通用标准,易于维护拓展。




    4. ESLint 可以解决 删除之后引入新的无用变量的问题 ,最典型的就是删除了某个函数,这个函数内部的某个函数也可能会变成无效代码。ESLint 会 重复执行 fix 函数,直到不再有新的可修复错误为止。





    为什么要 fork 下来改写它?




    1. 官方的 no-unused-vars 默认是不考虑 export 出去的变量的,而经过我对源码的阅读发现,仅仅 修改少量的代码 就可以打破这个限制,让 export 出去的变量也可以被分析,在模块内部是否使用。




    2. 第一步的改写后,很多 export 出去的变量 被其他模块引用 ,但由于在 模块内部未使用 ,也会 被分析为未使用变量 。所以需要给 rule 提供一个 varsPattern 的选项,把分析范围限定在 ts-unused-exports 给出的 导出未使用变量 中,如 varsPattern: '^foo$|^bar$'




    3. 官方的 no-unused-vars 只给出提示,没有提供 自动修复 的方案,需要自己编写,下面详细讲解。




    如何删除变量


    当我们在 IDE 中编写代码时,有时会发现保存之后一些 ESLint 飘红的部分被自动修复了,但另一部分却没有反应。
    这其实是 ESLint 的 rule fixer 的作用。
    参考官方文档的 Apply Fixer 章节,每个 ESLint Rule 的编写者都可以决定自己的这条规则 是否可以自动修复,以及如何修复。
    修复不是凭空产生的,需要作者自己对相应的 AST 节点做分析、删除等操作,好在 ESLint 提供了一个 fixer 工具包,里面封装了很多好用的节点操作方法,比如 fixer.remove()fixer.replaceText()
    官方的 no-unused-vars 由于稳定性等原因未提供代码的自动修复方案,需要自己对这个 rule 写对应的 fixer 。官方给出的解释在 Add fix/suggestions to no-unused-vars rule · Issue #14585 · eslint/eslint


    核心改动


    把 ESLint Plugin 单独拆分到一个目录中,结构如下:


    packages/eslint-plugin-deadvars
    ├── ast-utils.js
    ├── eslint-plugin.js
    ├── eslint-rule-typescript-unused-vars.js
    ├── eslint-rule-unused-vars.js
    ├── eslint-rule.js
    └── package.json



    • eslint-plugin.js : 插件入口,外部引入后才可以使用 rule




    • eslint-rule-unused-vars.js : ESLint 官方的 eslint/no-unused-vars 代码,主要的核心代码都在里面。




    • eslint-rule-typescript-unused-vars : typescript-eslint/no-unused-vars 内部的代码,继承了 eslint/no-unused-vars ,增加了一些 TypeScript AST 节点的分析。




    • eslint-rule.js :规则入口,引入了 typescript rule ,并且利用 eslint-rule-composer 给这个规则增加了自动修复的逻辑。




    ESLint Rule 改动


    我们的分析涉及到删除,所以必须有一个严格的限定范围,就是 exports 出去 且被 ts-unused-exports 认定为 外部未使用 的变量。
    所以考虑增加一个配置 varsPattern ,把 ts-unused-exports 分析出的未使用变量名传入进去,限定在这个名称范围内。
    主要改动逻辑是在 collectUnusedVariables 这个函数中,这个函数的作用是 收集作用域中没有使用到的变量 ,这里把 exports 且不符合变量名范围 的全部跳过不处理。


    else if (
    config.varsIgnorePattern &&
    config.varsIgnorePattern.test(def.name.name)
    ) {
    // skip ignored variables
    continue;
    + } else if (
    + isExported(variable) &&
    + config.varsPattern &&
    + !config.varsPattern.test(def.name.name)
    +) {
    + // 符合 varsPattern
    + continue;
    + }

    这样外部就可以这样使用这样的方式来限定分析范围:


    rules: {
    '@deadvars/no-unused-vars': [
    'error',
    { varsPattern: '^foo$|^bar$' },
    ]
    }

    接着删除掉原版中 收集未使用变量时isExported 的判断,把 exports 出去但文件内部未使用 的变量也收集起来。由于上一步已经限定了变量名,所以这里只会收集到 ts-unused-exports 分析出来的变量。


    if (
    !isUsedVariable(variable) &&
    - !isExported(variable) &&
    !hasRestSpreadSibling(variable)
    ) {
    unusedVars.push(variable);
    }

    ESLint Rule Fixer


    接下来主要就是增加自动修复,这部分的逻辑在 eslint-rule.js 中,简单来说就是对上一步分析出来的各种未使用变量的 AST 节点进行判断和删除。
    贴一下简化的函数处理代码:


    module.exports = ruleComposer.mapReports(rule, (problem, context) => {
    problem.fix = fixer => {
    const { node } = problem;
    const { parent } = node;

    // 函数节点
    switch (parent.type) {
    case 'FunctionExpression':
    case 'FunctionDeclaration':
    case 'ArrowFunctionExpression':
    // 调用 fixer 进行删除
    return fixer.remove(parent);
    ...
    ...
    default:
    return null;
    }
    };
    return problem;
    });

    目前会对以下几种节点类型进行删除:




    • FunctionExpression




    • FunctionDeclaration




    • ArrowFunctionExpression




    • ImportSpecifier




    • ImportDefaultSpecifier




    • ImportNamespaceSpecifier




    • VariableDeclarator




    • TSEnumDeclaration




    后续新增节点的删除逻辑,只需要维护这个文件即可。


    无用文件删除


    之前基于 webpack-deadcode-plugin 做了一版无用代码删除,但是在实际使用的过程中,发现一些问题。


    首先是 速度太慢 ,这个插件会基于 webpack 编译的结果来分析哪些文件是无用的,每次使用都需要编译一遍项目。


    而且前几天加入了 fork-ts-checker-webpack-plugin 进行类型检查之后, 这个删除方案突然失效了 ,检测出来的只有 .less 类型的无用文件,经过和排查后发现是这个插件的锅,它会把 src 目录下的所有 ts 文件 都加入到 webpack 的依赖中,也就是 compilation.fileDependencies (可以尝试开启这个插件,在开发环境试着手动改一个完全未导入的 ts 文件,一样会触发重新编译)


    而 deadcode-plugin 就是依赖 compilation.fileDependencies 这个变量来判断哪些文件未被使用,所有 ts 文件都在这个变量中的话,扫描出来的无用文件自然就只有其他类型了。


    这个行为应该是插件的官方有意而为之,考虑如下情况:


    // 直接导入一个 TS 类型
    import { IProps } from "./type.ts";

    // use IProps

    在使用旧版的 fork-ts-checker-webpack-plugin 时,如果此时改动了 IProps 造成了类型错误,是不会触发 webpack 的编译报错的。


    经过排查,目前官方的行为好像是把 tsconfig 中的 include 里的所有 ts 文件加入到依赖中,方便改动触发编译,而我们项目中的 include["src/**/*.ts"] ,所以……


    具体讨论可以查看这个 Issue: Files that provide only type dependencies for main entry and unused files are not being checked for


    方案


    首先尝试在 deadcode 模式中手动删除 fork-ts-checker-webpack-plugin,这样可以扫描出无用依赖,但是上文中那样从文件中只导入类型的情况,还是会被认为是无用的文件而误删。


    考虑到现实场景中单独建一个 type.ts 文件书写接口或类型的情况比较多,只好先放弃这个方案。


    转而一想, pzavolinsky/ts-unused-exports 这个工具既然都能分析出
    所有文件的 导入导出变量的依赖关系 ,那分析出未使用的文件应该也是小意思才对。


    经过源码调试,大概梳理出了这个工具的原理:



    1. 通过 TypeScript 内置的 ts.parseJsonConfigFileContent API 扫描出项目内完整的 ts 文件路径。


     {
    "path": "src/component/A",
    "fullPath": "/Users/admin/works/test/src/component/A.tsx",
    {
    "path": "src/component/B",
    "fullPath": "/Users/admin/works/test/apps/app/src/component/B.tsx",
    }
    ...


    1. 通过 TypeScript 内置的一些 compile API 分析出文件之间的 exports 和 imports 关系。


    {
    "path": "src/component/A",
    "fullPath": "/Users/admin/works/test/src/component/A.tsx",
    "imports": {
    "styled-components": ["default"],
    "react": ["default"],
    "src/components/B": ["TestComponentB"]
    },
    "exports": ["TestComponentA"]
    }


    1. 根据上述信息来分析出每个文件中每个变量的使用次数,筛选出未使用的变量并且输出。


    到此思路也就有了,把所有文件中的 imports 信息取一个合集,然后从第一步的文件集合中找出未出现在 imports 里的文件即可。


    一些值得一提的改造


    循环删除文件


    在第一次检测出无用文件并删除后,很可能会暴露出一些新的无用文件。
    比如以下这样的例子:


    [
    {
    "path": "a",
    "imports": "b"
    },
    {
    "path": "b",
    "imports": "c"
    },
    {
    "path": "c"
    }
    ]

    文件 a 引入了文件 b,文件 b 引入了文件 c。


    第一轮扫描的时候,没有任何文件引入 a,所以会把 a 视作无用文件。


    由于 a 引入了 b,所以不会把 b 视作无用的文件,同理 c 也不会视作无用文件。


    所以 第一轮删除只会删掉 a 文件


    只要在每次删除后,把 files 范围缩小,比如第一次删除了 a 以后,files 只留下:


    [
    {
    path: "b",
    imports: "c",
    },
    {
    path: "c",
    },
    ];

    此时会发现没有文件再引入 b 了,b 也会被加入无用文件的列表,再重复此步骤,即可删除 c 文件。


    支持 Monorepo


    原项目只考虑到了单个项目和单个 tsconfig 的处理,而如今 monorepo 已经非常流行了,monorepo 中每个项目都有自己的 tsconfig,形成一个自己的 project,而经常有项目 A 里的文件或变量被项目 B 所依赖使用的情况。


    而如果单独扫描单个项目内的文件,就会把很多被子项目使用的文件误删掉。


    这里的思路也很简单:




    1. 增加 --deps 参数,允许传入多个子项目的 tsconfig 路径。




    2. 过滤子项目扫描出的 imports 部分,找出从别名为 @main的主项目中引入的依赖(比如 import { Button } from '@main/components'




    3. 把这部分 imports 合并到主项目的依赖集合中,共同进行接下来的扫描步骤。




    支持自定义文件扫描


    TypeScript 提供的 API,默认只会扫描 .ts, .tsx 后缀的文件,在开启 allowJS 选项后也会扫描 .js, .jsx 后缀的文件。
    而项目中很多的 .less, .svg 的文件也都未被使用,但它们都被忽略掉了。


    这里我断点跟进 ts.parseJsonConfigFileContent 函数内部,发现有一些比较隐蔽的参数和逻辑,用比较 hack 的方式支持了自定义后缀。


    当然,这里还涉及到了一些比较麻烦的改造,比如这个库原本是没有考虑 index.ts, index.less 同时存在这种情况的,通过源码的一些改造最终绕过了这个限制。


    目前默认支持了 .less, .sass, .scss 这些类型文件的扫描 ,只要你确保该后缀的引入都是通过 import 语法,那么就可以通过增加的 extraFileExtensions 配置来增加自定义后缀。


    import * as ts from "typescript";

    const result = ts.parseJsonConfigFileContent(
    parseJsonResult.config,
    ts.sys,
    basePath,
    undefined,
    undefined,
    undefined,
    extraFileExtensions?.map((extension) => ({
    extension,
    isMixedContent: false,
    // hack ways to scan all files
    scriptKind: ts.ScriptKind.Deferred,
    }))
    );

    其他方案:ts-prune


    ts-prune 是完全基于 TypeScript 服务实现的一个 dead exports 检测方案。


    背景


    TypeScript 服务提供了一个实用的 API: findAllReferences ,我们平时在 VSCode 里右键点击一个变量,选择 “Find All References” 时,就会调用这个底层 API 找出所有的引用。


    ts-morph 这个库封装了包括 findAllReferences 在内的一些底层 API,提供更加简洁易用的调用方式。


    ts-prune 就是基于 ts-morph 封装而成。


    一段最简化的基于 ts-morph 的检测 dead exports 的代码如下:


    // this could be improved... (ex. ignore interfaces/type aliases that describe a parameter type in the same file)
    import { Project, TypeGuards, Node } from "ts-morph";

    const project = new Project({ tsConfigFilePath: "tsconfig.json" });

    for (const file of project.getSourceFiles()) {
    file.forEachChild((child) => {
    if (TypeGuards.isVariableStatement(child)) {
    if (isExported(child)) child.getDeclarations().forEach(checkNode);
    } else if (isExported(child)) checkNode(child);
    });
    }

    function isExported(node: Node) {
    return TypeGuards.isExportableNode(node) && node.isExported();
    }

    function checkNode(node: Node) {
    if (!TypeGuards.isReferenceFindableNode(node)) return;

    const file = node.getSourceFile();
    if (
    node.findReferencesAsNodes().filter((n) => n.getSourceFile() !== file)
    .length === 0
    )
    console.log(
    `[${file.getFilePath()}:${node.getStartLineNumber()}: ${
    TypeGuards.hasName(node) ? node.getName() : node.getText()
    }`
    );
    }

    优点




    1. TS 的服务被各种 IDE 集成,经过无数大型项目检测,可靠性不用多说。




    2. 不需要像 ESLint 方案那样,额外检测变量在文件内是否使用, findAllReferences 的检测范围包括文件内部,开箱即用。




    缺点




    1. 速度慢 ,TSProgram 的初始化,以及 findAllReferences 的调用,在大型项目中速度还是有点慢。




    2. 文档和规范比较差 ,ts-morph 的文档还是太简陋了,挺多核心的方法没有文档描述,不利于维护。




    3. 模块语法不一致 ,TypeScript 的 findAllReferences 并不识别 Dynamic Import 语法,需要额外处理 import() 形式导入的模块。




    4. 删除方案难做 ,ts-prune 封装了相对完善的 dead exports 检测方案,但作者似乎没有做自动删除方案的意思。这时 第二点的劣势就出来了,按照文档来探索删除方案非常艰难。看起来有个德国的小哥 好不容易说服作者 提了一个自动删除的 MR:Add a fix mode that automatically fixes unused exports (revival) ,但是最后因为内存溢出没通过 GithubCI,不了了之了。我个人把这套代码 fork 下来在公司内部的大型项目中跑了一下,也确实是内存溢出 ,看了下自动修复方案的代码,也都是很常规的基于 ts-morph 的 API 调用,猜测是底层 API 的性能问题?




    所以综合评估下来,最后还是选择了 ts-unused-exports + ESLint 的方案。


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

    收起阅读 »

    Android JNI 原理

    JNI:Java Native Interface1. 系统源码中的 JNI2. MediaRecorder 框架中的 JNIMediaRecorder,用于录音和录像。2.1 Java Framework 层的 MediaRecorder2.2 JNI 层的...
    继续阅读 »
    • JNI:Java Native Interface

    image.png

    1. 系统源码中的 JNI

    image.png

    2. MediaRecorder 框架中的 JNI

    MediaRecorder,用于录音和录像。

    image.png

    2.1 Java Framework 层的 MediaRecorder

    image.png

    image.png

    2.2 JNI 层的 MediaRecorder

    image.png

    2.3 Native 方法注册

    Native 方法注册分为静态注册和动态注册,其中静态注册多用于 NDK 开发,而动态注册多用于 Framework 开发。

    2.3.1 静态注册

    image.png

    image.png

    静态注册就是 Java 的 Native 方法通过方法指针来与 JNI 进行关联,如果 Java 的 Native 方法知道它在 JNI 中对应的函数指针,就可以避免上述的缺点,这就是动态注册。

    2.3.2 动态注册

    image.png

    image.png

    image.png

    3. 数据类型的转换

    Java 的数据类型到了 JNI 层就需要转换为 JNI 层的数据类型。

    3.1 基本数据类型的转换

    image.png

    3.2 引用数据类型的转换

    image.png

    image.png

    image.png

    4. 方法签名

    JNI 的方法签名的格式为: (参数签名格式...)返回值签名格式

    image.png

    image.png

    5. 解析 JNIEnv

    • JNIEnv
    • Java VM
    • JNINativeInterface
    • JNIInvokeInterface
    • AttachCurrentThread
    • DetachCurrentThread

    image.png

    image.png

    5.1 jfieldID 和 jmethodID

    image.png

    image.png

    image.png

    5.2 使用 jfieldID 和 jmethodID

    image.png

    image.png

    6. 引用类型

    • 本地引用(Local References)
    • 全局引用(Global References)
    • 弱全局引用(Weak Global References)

    6.1 本地引用

    image.png

    image.png

    • FindClass
    • DeleteLocalRef

    6.2 全局引用

    全局引用和本地引用几乎是相反的,它主要有以下特点:

    image.png

    image.png

    • NewGlobalRef
    • DeleteGlobalRef

    6.3 弱全局引用

    image.png

    image.png

    • NewWeakGlobalRef
    • DeleteWeakGlobalRef
    • IsSameObject
    收起阅读 »

    超详细的android so库的逆向调试

    好久没有写博客了,最近的精力全放在逆向上面。目前也只是略懂皮毛。android java层的逆向比较简单,主要就是脱壳 、反编译源码,通过xposed进行hook。接下来介绍一下,如何去调试hook native层的源码,也就是hook so文件。应用环境准备...
    继续阅读 »

    好久没有写博客了,最近的精力全放在逆向上面。目前也只是略懂皮毛。

    android java层的逆向比较简单,主要就是脱壳 、反编译源码,通过xposed进行hook。
    接下来介绍一下,如何去调试hook native层的源码,也就是hook so文件。

    应用环境准备

    首先,为了方便学习,一上来就hook第三方app难度极大,因此我们自己来创建一个native的项目,自己来hook自己的项目作为学习的练手点。

    创建默认的native application

    打开as,选择File -> new project -> naive c++ 创建包含c++的原生工程。

    hook1.png

    默认的native工程,帮我们实现了stringFromJNI方法,那我们就来探索如何hook这个stringFromJNI,并修改他的值。

    修改stringFromJNI方法,便于调试

    as默认实现的stringFromJNI只有在Activity onCreate的时候调用,为了便于调试,我们增加一个点击事件,每次点击重新调用,并且返回一个随机的值。

    java代码增加如下方法:

    	binding.sampleText.setOnClickListener {
    Log.e("MainActivity", "stringFromJNI")
    binding.sampleText.text = stringFromJNI()
    }

    修改native-lib.cpp代码:

    #include <jni.h>
    #include <string>

    using namespace std;

    int max1(int num1, int num2);
    #define random(x) rand()%(x)

    extern "C" JNIEXPORT jstring JNICALL
    Java_com_noober_naticeapplication_MainActivity_stringFromJNI(
    JNIEnv* env,
    jobject /* this */) {
    int result = max1(random(100), random(100));
    string hello = "Hello from C++";
    string hello2 = hello.append(to_string(result));
    return env->NewStringUTF(hello2.c_str());
    }


    int max1(int num1, int num2)
    {
    // 局部变量声明
    int result;

    if (num1 > num2)
    result = num1;
    else
    result = num2;

    return result;
    }

    修改的代码很简单,相信不会 c++ 的同学也看得懂,就是随机输入两个数,取其中小的那一位拼接在“Hello from C++”后面,并返回。主要目的是让我们每次点击的时候,返回内容可以动态。

    修改androidManifest文件

    在application中增加下面两行代码:

        android:extractNativeLibs="true"
    android:debuggable="true"

    android:debuggable: 让我们可以对apk进行调试,如果是第三方已经打包好了app,我们需要对其manifest文件进行修改,增加这行代码,然后进行重打包,否则无法进行so的调试。

    android:extractNativeLibs: 很多人在进行调试的时候发现ida pro一切正常,但是却一直没有加载我们的libnative -lib.so, 是因为缺少这行代码。如果不加,可能会使so直接自身的base.apk进行加载,导致ida pro无法识别。

    修改CMakeLists.txt

    在cmakelists中增加下面代码。so文件生成路径,这样编译之后就可以在main-cpp-jniLibs目录下找到生产的so文件。

    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/jniLibs/${ANDROID_ABI})

    编译运行,获取so

    上述工作做好之后,直接编译运行,同时会生成4个so文件,我们取手机运行时对应使用的那个so进行hook。
    我这边使用的是arm64-v8a目录下的libnative-lib.so。

    hook2.png

    hook环境准备

    • 系统:windows 10 64位
    • 工具ida pro 7.5
    • java8环境
    • android sdk tools和adb工具
    • arm64-v8a目录下的libnative-lib.so
    • android 真机

    使用ida pro进行hook

    adb与手机的准备

    1. 首先找到ida pro的dbgsrv文件夹,里面有很多server文件

    hook3.png

    64代表的含义是64位,否则就是32位,我们根据我们需要调试的so的指令集进行选择。因为我这边调试的是arm64-v8a,这里我们就选择android_server64的文件。连接真机后,打开cmd,输入以下指令:

    adb push "\\Mac\Home\Desktop\IDA PRO 7.5 (x86, x64, ARM, ARM64)\dbgsrv\android_server64"  /data/local/tmp
    1. 如果是真机,则需要输入su,模拟器不需要

       #真机
      su
    2. 修改权限

       chmod 777 /data/local/tmp/android_server64
    3. 运行

       /data/local/tmp/android_server64

    hook9.png

    1. 新打开一个cmd,在本地执行adb 做端口转发

       adb forward tcp:23946 tcp:23946

    ida pro的工作准备

    1. 打开ida pro,因为我们的so是64位的,所以打开ida64.exe。点击new,选择libnative-lib.so。

    2. 选择debugger-select debugger

    hook4.png

    1. 选择Remote ARM Linux/Android debugger

    hook5.png

    1. 点击debugger-Debugger options

    勾选Suspend on process entry point ,也就是在断点处进行挂起暂停

    hook6.png

    1. 点击debugger-Process options

    填写hostname为localhost

    hook10.png

    1. 找到exports标签,ctrl+f,搜索java关键字,找到我们要hook的函数。

    hook7.png

    1. 双击打开,按F5,进行反汇编操作。这样就可以看到反汇编之后的c ++代码了。然后我们随便加上断点进行调试。

    hook8.png

    1. 执行adb命令,进入调试状态,也就是打开我们要调试的app的启动activity,我这边如下:

       adb shell am start -D -n com.noober.naticeapplication/com.noober.naticeapplication.MainActivity
    2. 点击debugger-Attach to process

    选择我们需要调试的进程。

    hook11.png

    1. adb 执行如下命令,关联运行的so与本地要调试的so。

      jdb -connect com.sun.jdi.SocketAttach:hostname=127.0.0.1,port=8700
    2. 此时ida卡在libc.so的位置,点击继续执行,弹出如下界面,关联so到本地,选择same。如果没有弹出则需要通过快捷键ctrl+s, 打开所有已经加载的so,找到我们的libnative-lib.so

    hook14.png

    1. 此时就会自动进入断点。

    hook1.png

    使用ida pro进行调试

    ida pro 常用调试快捷键

    • F2下断点
    • F7单步步入
    • F8单步步过
    • F9执行到下个断点
    • G调到函数地址
    • Debugger-debugger windows-locals 查看变量

    进行调试

    1. 简单分析反汇编代码,我们发现返回值是v5,通过f8,执行到return的上一行。打开locals, 获取所有变量的值。

    locals.png

    1. 复制bytes的地址0x7FFE2CDEB9LL,切换到代码界面,输入快捷键g,输入地址跳转。这样我们便从内存中得到了数据结果,可以看出本次返回的值就是"Hello from c++89"

    result.png

    1. 当然我们也可以在locals中直接修改值,这样就达到了我们hook so动态修改数据的目的。

    收起阅读 »

    Android 自动化交互实践

    Android 自动化交互可以代替人工完成重复性的工作,包括通过自动操作 App 进行黑盒测试和第三方 App 的自动运行。常见的自动化交互包含启动 App、view 的点击、拖拽和文本输入等。随着 App 安防能力的提升,要想实现完整流程的自动化交互变的越来...
    继续阅读 »

    Android 自动化交互可以代替人工完成重复性的工作,包括通过自动操作 App 进行黑盒测试和第三方 App 的自动运行。常见的自动化交互包含启动 App、view 的点击、拖拽和文本输入等。随着 App 安防能力的提升,要想实现完整流程的自动化交互变的越来越困难,本文主要探讨目前常见的自动化交互方案以及不同方案的优劣和应用场景。

    1 传统执行脚本方案

    ADB 是 Google 提供的能够和 Android 设备进行交互的命令行工具,我们可以编写脚本按照事先设计好的顺序,一个一个执行各个事件。ADB 执行操作需要事先获取界面元素的坐标(获取坐标方法可以利用 uiautomator 或者 dump xml 的方法,这里不是讨论的重点),然后把坐标传入作为命令行参数。

    adb shell input tap 100 500

    上面命令是模拟点击屏幕坐标为(100, 500)处的控件。

    adb shell input swipe 100 500 200 600

    上面命令是模拟手指在屏幕上向右下方滑动的一个操作。

    adb shell input keyevent "KEYCODE_BACK"

    上面命令模拟返回按键的点击。

    一次完整的自动化交互流程可由上面一系列命令顺序执行。

    ADB 脚本方式的优点

    1. 实现简单,只需要获取目标元素的坐标等简单信息即可完成相关操作
    2. 可以实现对 webview 的自动化交互\

    ADB 脚本方式的缺点

    1. 灵活度不够,依赖于写死的坐标,App 界面变更引起的 view 位置变换会让脚本中相关命令无法执行,需要重新分析页面坐标
    2. 需要建立 ADB 链接或套接字链接,交互过程中网络状况的变化会影响自动化交互效果\

    ADB 脚本方式应用场景

    1. 交互简单、迭代频率低,安防级别比较低的 App
    2. webview 页面,flutter 开发的 App

    2 Android 原生方法实现自动化交互

    我们可以借助各种插件化框架来控制 App 页面的界面元素,其中一种思路就是在插件中借助 ActivityLifecycleCallbacks 来监听各个 activity 的生命周期。

    public class MyApplication extends Application {
    private static final String TAG = "MyApplication";
    //声明一个监听Activity们生命周期的接口
    private ActivityLifecycleCallbacks activityLifecycleCallbacks = new ActivityLifecycleCallbacks() {
    /**
    * application下的每个Activity声明周期改变时,都会触发以下的函数。
    */
    @Override
    public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
    //如何区别参数中activity代表你写的哪个activity。
    if (activity.getClass() == MainActivity.class)
    Log.d(TAG, "MainActivityCreated.");
    else if(activity.getClass()== SecondActivity.class)
    Log.d(TAG, "SecondActivityCreated.");
    }

    @Override
    public void onActivityStarted(Activity activity) {
    Log.d(TAG, "onActivityStarted.");
    }

    @Override
    public void onActivityResumed(Activity activity) {
    Log.d(TAG, "onActivityResumed.");
    }

    @Override
    public void onActivityPaused(Activity activity) {
    Log.d(TAG, "onActivityPaused.");
    }

    @Override
    public void onActivityStopped(Activity activity) {
    Log.d(TAG, "onActivityStopped.");
    }

    @Override
    public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
    }

    @Override
    public void onActivityDestroyed(Activity activity) {
    Log.d(TAG, "onActivityDestroyed.");
    }
    };

    @Override
    public void onCreate() {
    super.onCreate();
    //注册自己的Activity的生命周期回调接口。![Alt text](./WechatIMG59.png)

    registerActivityLifecycleCallbacks(activityLifecycleCallbacks);
    }

    @Override
    public void onTerminate() {
    //注销这个接口。
    unregisterActivityLifecycleCallbacks(activityLifecycleCallbacks);
    super.onTerminate();
    }
    }

    监听到 activity 的活动后,可以借助 uiautomator 分析 activity 界面元素的 viewId 以及属性,不同情况的界面 view 可以采用不同的自动化方法。

    2.1 简单 view 的处理方式

    如下图: image.png

    像这类 view,可以直接获取到 resource id ,并且确认可点击属性为 true,操作方式比较简单, 可以在监听到的 activity 生命周期中执行如下操作:

    int fl_btn = activity.getResources().getIdentifier("dashboard_title",         "id", "com.android.settings");
    View v = activity.findViewById(fl_btn);

    v.performClick();

    2.2 隐藏属性的 view 的处理方式

    在一些对 view 的属性进行隐藏,特别是利用 React Native 等混合开发的页面,上面的方法不再生效,如下图所示的 view: image.png

    如图,选中的 viewgroup 及其子 view 的 clickable 属性均为 false,并且无法获取到 view 的 resource id,这时候可以利用图中 dump 出的布局信息,借助 Xpath 元素定位工具来获取到界面的 view,由于这些 view 的点击属性为 false,因此通过调用 performClick 来实现点击的方法已经无效,此时考虑在 click 更底层的与触摸事件传递相关的比较重要的类:MotionEvent, MotionEvent 可以仿真几乎所有的交互事件,包括点击,滑动,双指操作等。以单击为例:

        private void simulateClick(View view, float x, float y) {
    long time = SystemClock.uptimeMillis();//必须是 SystemClock.uptimeMillis()。

    MotionEvent downEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, x, y, 0);

    time += 500;

    MotionEvent upEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_UP, x, y, 0);

    view.onTouchEvent(downEvent);
    view.onTouchEvent(upEvent);
    }

    如果是滑动操作,可以在起始位置中间添加若干 ACTION_MOVE 类型的 MotionEvent. 综上所述,借助系统原生方法时间交互自动化的优缺点大致如下:

    借助插件框架实现自动化交互的优点

    1. 可维护性强,因为可以直接获取到界面的 view 对象,因此即使页面布局发生变化,只要 view 还存在,就不需要对代码进行修改
    2. 仿真效果更好,比脚本方式更接近真人操作

    借助插件框架实现自动化交互的不足

    1. 对 webview、flutter 框架 App 支持不好

    应用场景

    1. 版本迭代频繁的 App
    2. 非 flutter 框架开发的 App

    上面分析了两种常用的模拟真实用户进行自动化操作的方法,脚本方式和调用原生方法的方式,这两种方法基本上可以完成简单的交互流程,在此基础上,我们还可以去深究一些更深层次的交互实现,比如自动化过各种验证等,也可以基于这两种方法来完成。

    收起阅读 »

    Android 面试准备进行曲-Android 基础知识

    基础部分Activity生命周期onCreate() -> onStart() -> onResume() -> onPause() -> onStop() -> onDetroy() 图片简要说明启动 onCreate...
    继续阅读 »

    基础部分

    Activity生命周期

    onCreate() -> onStart() -> onResume() -> onPause() -> onStop() -> onDetroy()

    11.webp 图片简要说明

    • 启动 onCreate -> onStart -> onResume
    • 被覆盖/ 回到当前界面 onPause -> / -> onResume
    • 在后台 onPause -> onStop
    • 后退回到 onRestart -> onStart -> onResume
    • 退出 onPause -> onStop -> onDestory

    另外说一下 其他两个比较重要的 方法

    • onSaveInstanceState : (1)Activity被覆盖或退居后台,系统资源不足将其杀死,此方法会被调用;(2)在用户改变屏幕方向时,此方法会被调用 (系统先销毁当前的Activity,然后再重建一个新的,调用此方法时,我们可以保存一些临时数据);(3)在当前Activity跳转到其他Activity或者按Home键回到主屏,自身退居后台时, 系统调用此方法是为了保存当前窗口各个View组件的状态。onSaveInstanceState该方法调用在onStop之前,但和onPause没有时序关系。 不过一般onSaveInstanceState() 保存临时数据为主,而 onPause() 适用于对数据的持久化保存。

    • onRestoreInstanceState : onRestoreInstanceState的调用顺序是在onStart之后。主要用于 恢复一些onSaveInstanceState 方法中保存的数据

    onStart()和onResume()/onPause()和onStop()的区别

    onStart()与onStop()是从Activity是否可见这个角度调用的 onResume()和onPause()是从Activity是否显示在前台这个角度来回调的 在实际使用没其他明显区别。

    Activity A 跳转 Activity B的问题

    Activity A启动另一个Activity B会回调的方法: Activity A的onPause() -->Activity B的onCreate()-->onStart()-->onResume()-->Activity A的onStop();

    如果Activity B是完全透明的,则最后不会调用Activity A的onStop();如果是对话框Activity,则最后不会调用Activity A的onStop();

    Activity 启动流程

    22.webp 调用startActivity()后经过重重方法会转移到ActivityManagerService的startActivity(),并通过一个IPC回到ActivityThread的内部类ApplicationThread中,并调用其scheduleLaunchActivity()将启动Activity的消息发送并交由Handler H处理。 Handler H对消息的处理会调用handleLaunchActivity()->performLaunchActivity()得以完成Activity对象的创建和启动。

    参考地址:Activity启动流程

    Fragment 生命周期

    Fragment从创建到销毁整个生命周期中涉及到的方法依次为: onAttach()->onCreate()-> onCreateView()->onActivityCreated()->onStart()->onResume()->onPause()->onStop()->onDestroyView()->onDestroy()->onDetach(), 其中和Activity有不少名称相同作用相似的方法,而不同的方法有:

    onAttach():当Fragment和Activity建立关联时调用

    onCreateView():当Fragment创建视图时调用

    onActivityCreated():当与Fragment相关联的Activity完成onCreate()之后调用

    onDestroyView():在Fragment中的布局被移除时调用

    onDetach():当Fragment和Activity解除关联时调用

    Activity 与 Fragment 通信

    1. 对于Activity和Fragment之间的相互调用

    (1)Activity调用Fragment 直接调用就好,Activity一般持有Fragment实例,或者通过Fragment id 或者tag获取到Fragment实例 (2)Fragment调用Activity 通过activity设置监听器到Fragment进行回调,或者是直接在fragment直接getActivity获取到activity实例

    1. Activity如果更好的传递参数给Fragment

    如果直接通过普通方法的调用传递参数的话,那么在fragment回收后恢复不能恢复这些数据。google给我们提供了一个方法 setArguments(bundle) 可以通过这个方法传递参数给fragment,然后在fragment中用getArguments获取到。能保证在fragment销毁重建后还能获取到数据

    Service 启动及生命周期

    service 启动方式

    • 不可通信Service 。 通过startService()启动,不跟随调用者关闭而关闭
    • 可通信Service 。 通过bindService()方式进行启动。跟随调用者关闭而关闭

    以上两种Servcie 默认都存在于调用者一样的进程中,如果想要设置不一样的进程中则需要在 AndroidManifest.xml 中 配置 android:process = Remote 属性

    生命周期 :

    • 通过startService()这种方式启动的service,生命周期 :startService() --> onCreate()--> onStartConmon()--> onDestroy()。

    需要注意几个问题

    1. 当我们通过startService被调用以后,多次在调用startService(),onCreate()方法也只会被调用一次,
    2. 而onStartConmon()会被多次调用当我们调用stopService()的时候,onDestroy()就会被调用,从而销毁服务。
    2. 当我们通过startService启动时候,通过intent传值,在onStartConmon()方法中获取值的时候,一定要先判断intent是否为null。
    • 通过bindService()方式进行绑定,这种方式绑定service,生命周期走法:bindService-->onCreate()-->onBind()-->unBind()-->onDestroy()

    bindService的优点 这种方式进行启动service好处是更加便利activity中操作service,比如加入service中有几个方法,a,b ,如果要在activity中调用,在需要在activity获取ServiceConnection对象,通过ServiceConnection来获取service中内部类的类对象,然后通过这个类对象就可以调用类中的方法,当然这个类需要继承Binder对象

    Service 通信方式

    1. 创建继承Binder的内部类,重写Service的onBind方法 返回 Binder 子类,重写ServiceConnection,onServiceConnected时调用逻辑方法 绑定服务。

    2. 通过接口Iservice调用Service方法

    IntentService对比Service

    IntentService是Service的子类,是一个异步的,会自动停止的服务,很好解决了传统的Service中处理完耗时操作忘记停止并销毁Service的问题

    优点:

    • 所有请求处理完成后,IntentService会自动停止,无需调用stopSelf()方法停止
    • IntentService不会阻塞UI线程,而普通Serveice会导致ANR异常
    • Intentservice若未执行完成上一次的任务,将不会新开一个线程,是等待之前的任务完成后,再执行新的任务,等任务完成后再次调用stopSelf()
    • 为Service的onBind()提供默认实现,返回null;

    提高service的优先级

    1. 在AndroidManifest.xml文件中对于intent-filter可以通过android:priority = “1000”这个属性设置最高优先级,1000是最高值,如果数字越小则优先级越低,同时实用于广播。
    2. onStartCommand方法,手动返回START_STICKY。
    3. 监听系统广播判断Service状态。
    4. Application加上Persistent属性。
    5. 在onStartCommand里面调用 startForeground()方法把Service提升为前台进程级别,然后再onDestroy里面调用stopForeground ()方法。
    6. 在onDestroy方法里发广播重启service。

    service +broadcast 方式,就是当service走ondestory的时候,发送一个自定义的广播,当收到广播的时候,重新启动service。

    延伸:进程保活(毒瘤)

    黑色保活:不同的app进程,用广播相互唤醒(包括利用系统提供的广播进行唤醒)
    白色保活:启动前台Service
    灰色保活:利用系统的漏洞启动前台Service

    黑色保活 所谓黑色保活,就是利用不同的app进程使用广播来进行相互唤醒。举个3个比较常见的场景: 场景1:开机,网络切换、拍照、拍视频时候,利用系统产生的广播唤醒app 场景2:接入第三方SDK也会唤醒相应的app进程,如微信sdk会唤醒微信,支付宝sdk会唤醒支付宝。由此发散开去,就会直接触发了下面的 场景3 场景3:假如你手机里装了支付宝、淘宝、天猫、UC等阿里系的app,那么你打开任意一个阿里系的app后,有可能就顺便把其他阿里系的app给唤醒了。(只是拿阿里打个比方,其实BAT系都差不多)

    白色保活 白色保活手段非常简单,就是调用系统api启动一个前台的Service进程,这样会在系统的通知栏生成一个Notification,用来让用户知道有这样一个app在运行着,哪怕当前的app退到了后台。如下方的LBE和QQ音乐这样:

    灰色保活 灰色保活,这种保活手段是应用范围最广泛。它是利用系统的漏洞来启动一个前台的Service进程,与普通的启动方式区别在于,它不会在系统通知栏处出现一个Notification,看起来就如同运行着一个后台Service进程一样。这样做带来的好处就是,用户无法察觉到你运行着一个前台进程(因为看不到Notification),但你的进程优先级又是高于普通后台进程的。那么如何利用系统的漏洞呢,大致的实现思路和代码如下: 思路一:API < 18,启动前台Service时直接传入new Notification(); 思路二:API >= 18,同时启动两个id相同的前台Service,然后再将后启动的Service做stop处理 熟悉Android系统的童鞋都知道,系统出于体验和性能上的考虑,app在退到后台时系统并不会真正的kill掉这个进程,而是将其缓存起来。打开的应用越多,后台缓存的进程也越多。在系统内存不足的情况下,系统开始依据自身的一套进程回收机制来判断要kill掉哪些进程,以腾出内存来供给需要的app。这套杀进程回收内存的机制就叫 Low Memory Killer ,它是基于Linux内核的 OOM Killer(Out-Of-Memory killer)机制诞生。

    思路二:后台播放无声音频,模拟前台服务,提高等级

    思路三:1像素界面

    思路四:在Activity的onDestroy()通过发送广播,并在广播接收器的onReceive()中启动Service

    进程的重要性,划分5级: 前台进程 (Foreground process) 可见进程 (Visible process) 服务进程 (Service process) 后台进程 (Background process) 空进程 (Empty process)

    什么是oom_adj?它是linux内核分配给每个系统进程的一个值,代表进程的优先级,进程回收机制就是根据这个优先级来决定是否进行回收。对于oom_adj的作用,你只需要记住以下几点即可: 进程的oom_adj越大,表示此进程优先级越低,越容易被杀回收;越小,表示进程优先级越高,越不容易被杀回收 普通app进程的oom_adj>=0,系统进程的oom_adj才可能<0 有些手机厂商把这些知名的app放入了自己的白名单中,保证了进程不死来提高用户体验

    Broadcast注册方式与区别

    Broadcast广播,注册方式主要有两种.

    • 第一种是静态注册,也可成为常驻型广播,这种广播需要在Androidmanifest.xml中进行注册,这中方式注册的广播,不受页面生命周期的影响,即使退出了页面,也可以收到广播这种广播一般用于想开机自启动啊等等,由于这种注册的方式的广播是常驻型广播,所以会占用CPU的资源。

    • 第二种是动态注册,而动态注册的话,是在代码中注册的,这种注册方式也叫非常驻型广播,收到生命周期的影响,退出页面后,就不会收到广播,我们通常运用在更新UI方面。这种注册方式优先级较高。最后需要解绑,否则会内存泄露

    广播是分为有序广播和无序广播。

    Broadcast 有几种形式

    普通广播:一种完全异步执行的广播,在广播发出之后,所有的广播接收器几乎都会在同一时刻接收到这条广播消息,因此它们接收的先后是随机的。

    有序广播:一种同步执行的广播,在广播发出之后,同一时刻只会有一个广播接收器能够收到这条广播消息,当这个广播接收器中的逻辑执行完毕后,广播才会继续传递,所以此时的广播接收器是有先后顺序的,且优先级(priority)高的广播接收器会先收到广播消息。有序广播可以被接收器截断使得后面的接收器无法收到它。

    本地广播:发出的广播只能够在应用程序的内部进行传递,并且广播接收器也只能接收本应用程序发出的广播。

    粘性广播:这种广播会一直滞留,当有匹配该广播的接收器被注册后,该接收器就会收到此条广播。

    部分Broadcast 之间的区别

    BroadcastReceiver: 是可以跨应用广播,利用Binder机制实现,支持动态和静态两种方式注册方式。

    LocalBroadcastReceiver: 是应用内广播,利用Handler实现,利用了IntentFilter的match功能,提供消息的发布与接收功能,实现应用内通信,效率和安全性比较高,仅支持动态注册。

    OrderedBroadcast : 调用sendOrderedBroadcast()发送,接收者会按照priority优先级从大到小进行排序,如优先级相同,先注册,先处理 广播接收者还能对广播进行截断和修改

    ContentProvider

    作为四大组件之一,ContentProvider主要负责存储和共享数据。与文件存储、SharedPreferences存储、SQLite数据库存储这几种数据存储方法不同的是,后者保存下的数据只能被该应用程序使用,而前者可以让不同应用程序之间进行数据共享,它还可以选择只对哪一部分数据进行共享,从而保证程序中的隐私数据不会有泄漏风险。

    app中有几个Context对象

    先看一下源码的解释

    /**
    * Interface to global information about an application environment. This is
    * an abstract class whose implementation is provided by
    * the Android system. It
    * allows access to application-specific resources and classes, as well as
    * up-calls for application-level operations such as launching activities,
    * broadcasting and receiving intents, etc.
    */
    public abstract class Context {
    /**
    * File creation mode: the default mode, where the created file can only
    * be accessed by the calling application (or all applications sharing the
    * same user ID).
    * <a href="http://www.jobbole.com/members/heydee@qq.com">@see</a> #MODE_WORLD_READABLE
    * <a href="http://www.jobbole.com/members/heydee@qq.com">@see</a> #MODE_WORLD_WRITEABLE
    */
    public static final int MODE_PRIVATE = 0x0000;

    public static final int MODE_WORLD_WRITEABLE = 0x0002;

    public static final int MODE_APPEND = 0x8000;

    public static final int MODE_MULTI_PROCESS = 0x0004;

    }

    源码中的注释是这么来解释Context的:Context提供了关于应用环境全局信息的接口。它是一个抽象类,它的执行被Android系统所提供。它允许获取以应用为特征的资源和类型,是一个统领一些资源(应用程序环境变量等)的上下文。就是说,它描述一个应用程序环境的信息(即上下文);是一个抽象类,Android提供了该抽象类的具体实现类;通过它我们可以获取应用程序的资源和类(包括应用级别操作,如启动Activity,发广播,接受Intent等)。

    3.webp 从上面的关系图我们已经可以得出答案了,在应用程序中Context的具体实现子类就是:Activity,Service,Application。那么Context数量=Activity数量+Service数量+1。当然如果你足够细心,可能会有疑问:我们常说四大组件,这里怎么只有Activity,Service持有Context,那Broadcast Receiver,Content Provider呢?Broadcast Receiver,Content Provider并不是Context的子类,他们所持有的Context都是其他地方传过去的,所以并不计入Context总数。

    Application Context 启动问题

    如果我们用ApplicationContext去启动一个LaunchMode为standard的Activity的时候会报错android.util.AndroidRuntimeException: Calling startActivity from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag. Is this really what you want?这是因为非Activity类型的Context并没有所谓的任务栈,所以待启动的Activity就找不到栈了。解决这个问题的方法就是为待启动的Activity指定FLAG_ACTIVITY_NEW_TASK标记位,这样启动的时候就为它创建一个新的任务栈,而此时Activity是以singleTask模式启动的。所有这种用Application启动Activity的方式不推荐使用,Service同Application。

    如何获取 Context对象

    1:View.getContext,返回当前View对象的Context对象,通常是当前正在展示的Activity对象。

    2:Activity.getApplicationContext,获取当前Activity所在的(应用)进程的Context对象,通常我们使用Context对象时,要优先考虑这个全局的进程Context。

    4:Activity.this 返回当前的Activity实例,如果是UI控件需要使用Activity作为Context对象,但是默认的Toast实际上使用ApplicationContext也可以。

    如何避免因为Context 造成内存泄漏

    一般Context造成的内存泄漏,几乎都是当Context销毁的时候,却因为被引用导致销毁失败,而Application的Context对象可以理解为随着进程存在的,所以我们总结出使用Context的正确姿势:

    1:当Application的Context能搞定的情况下,并且生命周期长的对象,优先使用Application的Context。

    2:不要让生命周期长于Activity的对象持有到Activity的引用。

    3:尽量不要在Activity中使用非静态内部类,因为非静态内部类会隐式持有外部类实例的引用,如果使用静态内部类,将外部实例引用作为弱引用持有。

    getApplication()和getApplicationContext() 区别

    其实我们通过程序打印 两个方法获得的对象 Application本身就是一个Context,所以这里获取getApplicationContext()得到的结果就是Application本身的实例。那么问题来了,既然这两个方法得到的结果都是相同的,那么Android为什么要提供两个功能重复的方法呢?

    实际上这两个方法在作用域上有比较大的区别。getApplication()方法的语义性非常强,一看就知道是用来获取Application实例的,但是这个方法只有在Activity和Service中才能调用的到。那么也许在绝大多数情况下我们都是在Activity或者Service中使用Application的,但是如果在一些其它的场景,比如BroadcastReceiver中也想获得Application的实例,这时就可以借助getApplicationContext()方法了。

    理解Activity,View,Window三者关系

    Activity像一个工匠(控制单元),Window像窗户(承载模型),View像窗花(显示视图)LayoutInflater像剪刀,Xml配置像窗花图纸。 1:Activity构造的时候会初始化一个Window,准确的说是PhoneWindow。 2:这个PhoneWindow有一个“ViewRoot”,这个“ViewRoot”是一个View或者说ViewGroup,是最初始的根视图。 3:“ViewRoot”通过addView方法来一个个的添加View。比如TextView,Button等 4:这些View的事件监听,是由WindowManagerService来接受消息,并且回调Activity函数。比如onClickListener,onKeyDown等。

    四种LaunchMode及其使用场景

    此处延伸:栈(First In Last Out)与队列(First In First Out)的区别 栈与队列的区别:

    队列先进先出,栈先进后出 对插入和删除操作的"限定"。 栈是限定只能在表的一端进行插入和删除操作的线性表。 队列是限定只能在表的一端进行插入和在另一端进行删除操作的线性表。 遍历数据速度不同

    standard 模式 这是默认模式,每次激活Activity时都会创建Activity实例,并放入任务栈中。使用场景:大多数Activity。 singleTop 模式 如果在任务的栈顶正好存在该Activity的实例,就重用该实例( 会调用实例的 onNewIntent() ),否则就会创建新的实例并放入栈顶,即使栈中已经存在该Activity的实例,只要不在栈顶,都会创建新的实例。使用场景如新闻类或者阅读类App的内容页面。 singleTask 模式 如果在栈中已经有该Activity的实例,就重用该实例(会调用实例的 onNewIntent() )。重用时,会让该实例回到栈顶,因此在它上面的实例将会被移出栈。如果栈中不存在该实例,将会创建新的实例放入栈中。使用场景如浏览器的主界面。不管从多少个应用启动浏览器,只会启动主界面一次,其余情况都会走onNewIntent,并且会清空主界面上面的其他页面。 singleInstance 模式 在一个新栈中创建该Activity的实例,并让多个应用共享该栈中的该Activity实例。一旦该模式的Activity实例已经存在于某个栈中,任何应用再激活该Activity时都会重用该栈中的实例( 会调用实例的 onNewIntent() )。其效果相当于多个应用共享一个应用,不管谁激活该 Activity 都会进入同一个应用中。使用场景如闹铃提醒,将闹铃提醒与闹铃设置分离。singleInstance不要用于中间页面,如果用于中间页面,跳转会有问题,比如:A -> B (singleInstance) -> C,完全退出后,在此启动,首先打开的是B。

    数据存储

    Android中提供哪些数据持久存储的方法?

    File 文件存储:写入和读取文件的方法和 Java中实现I/O的程序一样。

    SharedPreferences存储:一种轻型的数据存储方式,常用来存储一些简单的配置 信息,本质是基于XML文件存储key-value键值对数据。

    SQLite数据库存储:一款轻量级的关系型数据库,它的运算速度非常快,占用资源很少,在存储大量复杂的关系型数据的时可以使用。

    ContentProvider:四大组件之一,用于数据的存储和共享,不仅可以让不同应用程序之间进行数据共享,还可以选择只对哪一部分数据进行共享,可保证程序中的隐私数据不会有泄漏风险。

    SharePreferences 相关问题

    1. SharePreferences是一种轻型的数据存储方式,适用于存储一些简单的配置信息,如int、string、boolean、float和long。由于系统对SharedPreferences的读/写有一定的缓存策略,即在内存中有一份该文件的缓存,因此在多进程模式下,其读/写会变得不可靠,甚至丢失数据。

    2. context.getSharedPreferences()开始追踪的话,可以去到ContextImpl的getSharedPreferences(),最终发现SharedPreferencesImpl这个SharedPreferences的实现类,在代码中可以看到读写操作时都有大量的synchronized,因此它是线程安全

    3. 由于进程间是不能内存共享的,每个进程操作的SharedPreferences都是一个单独的实例,这导致了多进程间通过SharedPreferences来共享数据是不安全的,这个问题只能通过多进程间其它的通信方式或者是在确保不会同时操作SharedPreferences数据的前提下使用SharedPreferences来解决。

    SharePreferences 注意事项及优化办法

    1. 第一次getSharePreference会读取磁盘文件,异步读取,写入到内存中,后续的getSharePreference都是从内存中拿了。
    2. 第一次读取完毕之前 所有的get/set请求都会被卡住 等待读取完毕后再执行,所以第一次读取会有ANR风险。
    3. 所有的get都是从内存中读取。
    4. 提交都是 写入到内存和磁盘中 。apply跟commit的区别在于

    apply 是内存同步 然后磁盘异步写入任务放到一个单线程队列中 等待调用。方法无返回 即void commit 内存同步 只不过要等待磁盘写入结束才返回 直接返回写入成功状态 true or false 5. 从 Android N 开始, 不再支持 MODE_WORLD_READABLE & MODE_WORLD_WRITEABLE. 一旦指定, 会抛异常 。也不要用MODE_MULTI_PROCESS 迟早被放弃。 8.每次commit/apply都会把全部数据一次性写入到磁盘,即没有增量写入的概念 。 所以单个文件千万不要太大 否则会严重影响性能。

    建议用微信的第三方MMKV来替代SharePreference

    SP源码解析

    SQLite 相关问题

    • 使用事务做批量操作:

    使用SQLiteDatabase的beginTransaction()方法开启一个事务,将批量操作SQL语句转化成SQLiteStatement并进行批量操作,结束后endTransaction()

    • 及时关闭Cursor,避免内存泄漏

    • 耗时操作异步化:数据库的操作属于本地IO,通常比较耗时,建议将这些耗时操作放入异步线程中处理

    • ContentValues的容量调整:ContentValues内部采用HashMap来存储Key-Value数据,ContentValues初始容量为8,扩容时翻倍。因此建议对ContentValues填入的内容进行估量,设置合理的初始化容量,减少不必要的内部扩容操作

    • 使用索引加快检索速度:对于查询操作量级较大、业务对要求查询要求较高的推荐使用索引


    收起阅读 »

    性能优化2 - 内存、启动速度、卡顿、布局优化

    性能优化是在充分了解项目+java、android基础知识牢固的基础上的。内存优化基础知识回顾(看前面文章JVM详解):jVM内存模型,除了程序计数器以外,别的都会出现 OOM。JAVA对象的生命周期,创建、运行、死亡。GC对象可回收的判定:可达性分析。GC ...
    继续阅读 »

    性能优化是在充分了解项目+java、android基础知识牢固的基础上的。

    内存优化

    基础知识回顾(看前面文章JVM详解):

    jVM内存模型,除了程序计数器以外,别的都会出现 OOM。

    image.png

    JAVA对象的生命周期,创建、运行、死亡。
    GC对象可回收的判定:可达性分析。GC root(除了堆里的对象,虚拟机栈里的引用、方法区里的引用)。
    强软弱虚四种引用。
    GC回收算法:复制算法、标记清楚算法、标记整理算法。

    image.png

    App内存组成以及限制

    Android给每个App分配一个VM,让App运行在dalvik上,这样即使App崩溃也不会影响到系统。系统给VM分配了一定的内存大小,App可以申请使用的内存大小不能超过此硬性逻辑限制,就算物理内存富余,如果应用超出VM最大内存,就会出现内存溢出crash

    由程序控制操作的内存空间在heap上,分java heapsizenative heapsize

    • Java申请的内存在vm heap上,所以如果java申请的内存大小超过VM的逻辑内存限制,就会出现内存溢出的异常。
    • native层内存申请不受其限制,native层受native process对内存大小的限制

    总结:
    app运行在虚拟机上,手机给虚拟机分配内存是固定的,超出就oom。
    分配的内存大部分都是在堆上。分为java堆和native层的堆。
    java层的堆超过VM给的就oom。理论上native层无限制,但是底层实现native process对内存大小是有限制的。

    查看系统给App分配的内存限制

    不同手机给app分配的内存大小其实是不一样大的。

    1. 通过cmd指令 adb shell cat /system/build.prop

    image.png

    1. 通过代码 activityManager.getMemoryClass();
    ActivityManager activityManager =(ActivityManager)context.getSystemService(Context.ACTIVITY_SERVICE)

    activityManager.getMemoryClass();//以m为单位

    这些限制其实在 AndroidRuntime.cpp的startVm里,我们改不了,手机厂商可以改

    image.png

    Android低内存杀进程机制

    默认五个级别:空进程、后台进程、服务进程、可见进程、前台进程
    所以在保活里有一个做法就是给app提升优先级,给他整成前台、可见这样的。

    AMS里有一个oom_adj,会给各个进程进行评分,数字越大越容易被回收,前台进程是0,系统进程是负数,别的是正数。 image.png

    内存三大问题

    1、内存抖动
    profiler -> 内存波动图形呈 锯齿张、GC导致卡顿。
    原因是内存碎片很严重。因为android虚拟机的GC算法是标记清楚算法,所以频繁的申请内存、释放内存会让内存碎片化很严重,连续的内存越来越少,让GC非常频繁,导致了内存抖动。案例:在自定义View onDraw()里new对象

    2、内存泄漏 在当前应用周期内不再使用的对象被GC Roots引用,导致不能回收,使实际可使用内存变小。内存泄露如果越来越严重的话,最终会导致OOM。案例:context被长生命周期的东西引用。没有释放listener

    3、内存溢出 即OOM,OOM时会导致程序异常。Android设备出厂以后,java虚拟机对单个应用的最大内存分配就确定下来了,超出这个值就会OOM。案例:加载大图、内存泄露、内存抖动

    除了程序计数器以外 别的JVM部分都会OOM
    image.png

    常见分析工具

    1. Memory Analyzer Tools --- MAT

    MAT是Eclipse下的内存分析工具。
    用法:用cmd指令或者android studio 自带的profiler 截取一段数据,然后下载下来。得到一个.hprof

    image.png

    image.png
    然后用AMT打开,就能看预测泄露地方,有一个图表,基本没用。还有看哪个线程调用这个对象、深浅堆等等。挺难用的。

    image.png

    1. android studio自带的profiler

    谷歌官网:developer.android.google.cn/studio/prof…

    官网超级详细,还有视频,直接看官网的就行。 image.png

    选中一段区域,就能查看这段区域内存被具体哪个对象用了多少等等。
    我感觉还是用来看大势的,大的内存上涨、下落、起伏图。

    1. LeakCanary

    超级推荐LeakCanary!!!永远滴神
    具体内存泄露的检测,细节还得用LeakCanary。

    集成:
    build.gradle里添,跟集成第三方一样。
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'

    然后直接跑APP,跑完以后一顿点,点完以后会给推送。写了发现几个对象有泄露。点一下推送蓝会自动下载下来。

    image.png

    比如这就是我的问题,期初我认为是activity的context没有释放,其实是在dialog里使用了动画animation,但是动画的listener没有释放。 image.png

    Android内存泄漏常见场景以及解决方案

    1、资源性对象未关闭
    对于资源性对象不再使用时,应该立即调用它的close()函数,将其关闭,然后再置为null。例如Bitmap 等资源未关闭会造成内存泄漏,此时我们应该在Activity销毁时及时关闭。

    2、注册对象未注销
    例如BraodcastReceiver、EventBus未注销造成的内存泄漏,我们应该在Activity销毁时及时注销。

    3、类的静态变量持有大数据对象
    尽量避免使用静态变量存储数据,特别是大数据对象,建议使用数据库存储。

    4.单例造成的内存泄漏
    优先使用Application的Context,如需使用Activity的Context,可以在传入Context时使用弱引用进行封 装,然后,在使用到的地方从弱引用中获取Context,如果获取不到,则直接return即可。

    5、非静态内部类的静态实例
    该实例的生命周期和应用一样长,这就导致该静态实例一直持有该Activity的引用,Activity的内存资源 不能正常回收。此时,我们可以将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如 果需要使用Context,尽量使用Application Context,如果需要使用Activity Context,就记得用完后置 空让GC可以回收,否则还是会内存泄漏。

    6、Handler临时性内存泄漏
    Message发出之后存储在MessageQueue中,在Message中存在一个target,它是Handler的一个引用,Message在Queue中存在的时间过长,就会导致Handler无法被回收。如果Handler是非静态的, 则会导致Activity或者Service不会被回收。并且消息队列是在一个Looper线程中不断地轮询处理消息, 当这个Activity退出时,消息队列中还有未处理的消息或者正在处理的消息,并且消息队列中的Message 持有Handler实例的引用,Handler又持有Activity的引用,所以导致该Activity的内存资源无法及时回 收,引发内存泄漏。解决方案如下所示:
    1、使用一个静态Handler内部类,然后对Handler持有的对象(一般是Activity)使用弱引用,这 样在回收时,也可以回收Handler持有的对象。
    2、在Activity的Destroy或者Stop时,应该移除消息队列中的消息,避免Looper线程的消息队列中 有待处理的消息需要处理
    (Handler那篇有讲)

    7、容器中的对象没清理造成的内存泄漏
    在退出程序之前,将集合里的东西clear,然后置为null,再退出程序

    8、WebView
    WebView都存在内存泄漏的问题,在应用中只要使用一次WebView,内存就不会被释放掉。我们可以为 WebView开启一个独立的进程,使用AIDL与应用的主进程进行通信,WebView所在的进程可以根据业 务的需要选择合适的时机进行销毁,达到正常释放内存的目的。

    9、使用ListView时造成的内存泄漏
    在构造Adapter时,使用缓存的convertView。

    10、Bitmap
    80%的内存泄露都是Bitmap造成的,Bitmap有Recycle()方法,不用时要及时回收。但是如果遇到要用Bitmap直接用Glide就完事了,自己写10有8.9得出错。

    启动速度优化

    app启动流程

    ①点击桌面App图标,Launcher进程采用Binder IPC向AMS进程发起startActivity请求;

    ②AMS接收到请求后,向zygote进程发送创建进程的请求;

    ③Zygote进程fork出新的子进程,即App进程;

    ④App进程,通过Binder IPC向AMS进程发起attachApplication请求;

    ⑤AMS进程在收到请求后,进行一系列准备工作后,再通过binder IPC向App进程发送scheduleLaunchActivity请求;

    ⑥App进程的binder线程(ApplicationThread)在收到请求后,通过handler向主线程发送LAUNCH_ACTIVITY消息;

    ⑦主线程在收到Message后,通过反射机制创建目标Activity,并回调Activity.onCreate()等方法。

    ⑧到此,App便正式启动,开始进入Activity生命周期,执行完onCreate/onStart/onResume方法,UI渲染结束后便可以看到App的主界面。

    image.png

    启动状态

    APP启动状态分为:冷启动、热启动、温启动。
    冷启动:什么都没有,点击桌面图标,启动App。Zygote fork进程...
    热启动:app挂在后台,在点击图标切换回来。
    温启动:两者之间。

    启动耗时统计

    1.打log。displayed。有显示

    image.png

    2.cmd命令 adb shell am start -S -W +包名+Activity名

    CPU Profile

    具体怎么优化呢?得用到Android Studio自带的分析的工具CPU Profile。

    1. 打开CPU Profile,trace java Methods

    image.png

    image.png

    1. 跑项目,截开始的那段cpu运行图,得到启动的数据

    image.png

    1. 得到具体哪个方法执行时间的情况图

    Call Chart:黄、蓝、绿三色。黄色=系统、绿色=自己的、蓝色=第三方。自上而下表示调用顺序。越长就说明执行的时间越长。

    如:我发现极光的初始化时间还是挺长的,如果要优化可以将极光延迟初始化。
    image.png

    还有onCreat占用时间也挺长,主要在setContentView里,看看能不能将布局优化一下。
    image.png

    Flame Chart:跟Call Chart差不多,就是反过来的图,又称火焰图。 image.png

    Top Down Tree:这个就不是图标了,是方法直接的调用关系,每个方法的调用时间。一直往下点,可以找到占用时间较长的,可以优化的地方。从上往下的调用。

    Bottom Up Tree:跟top反向从下往上。

    image.png

    启动白屏

    在主题里配置一个背景。
    image.png

    StrictMode严苛模式

    可以在application里配置严苛模式,这样不规范的操作就会有log提示,或者闪退。

    image.png

    启动优化的点

    1). 合理的使用异步初始化、延迟初始化、懒加载机制。
    2). 启动过程避免耗时操作,如数据库 I/O操作不要放在主线程执行。
    3). 类加载优化:提前异步执行类加载。
    4). 合理使用IdleHandler进行延迟初始化。
    5). 简化布局

    卡顿优化

    分析工具

    1. Systrace是Android提供的一款工具,需要运行在Python环境下。

    配置:http://www.jianshu.com/p/e73768e66…

    Systrace主要是分析掉帧的情况的。帧:android手机一般都是60帧,所以1帧的时间=1000毫秒/60=16.6毫秒。也就是android手机16.6毫秒刷新一次,超过这个数就是掉帧了

    会有三色球,绿=正常,黄=一点不正常,红=掉帧严重。
    少几个没啥事,大面积的出现红、黄,就需要研究为啥掉帧了。 image.png

    可以看上面有一条CPU的横轴,绿色=正在执行,蓝色=等待,可以运行。紫色=休眠。白色=休眠阻塞。如果是出现了紫色就说明IO等耗时操作导致掉帧。如果紫+蓝比较多,说明cpu拿不到时间片,cpu很忙。

    CPU Profile

    这个就能看那个方法运行多少时间等,所以可以直接用android studio自带的分析。

    一般是在top down里一直点,耗时较多的,然后点到自己熟悉的地方,挨个分析。
    这是一个漫长的耗时的过程,可能找半天只找到几个地方能优化,然后每个几毫秒,加起来也没有直观的变快,但是性能优化就是这样的一个过程,积少成多。
    如:我经过查找就发现adapter的 notifyDataSetChanged因为不小心,有些地方多次调用了。
    甚至还有没有在线程进行io操作。 image.png

    布局优化

    经过上面的一顿操作,发现占时间大块的少不了setContentView。说明布局渲染视图还是挺费时的。

    减少层级

    自定义Viewmeasure、layout、draw这三个过程,都需要对整个视图树自顶向下的遍历,而且很多情况都会多次触发整个遍历过程(Linearlayout 的 weight等),所以如果层级太深,就会让整个绘制过程变慢,从而造成启动速度慢、卡顿等问题。
    而onDraw在频繁刷新时可能多次触发,因此 onDraw更不能做耗时操作,同时需要注意内存抖动。对于布局性能的检测,依然可以使用systrace与traceview按 照绘制流程检查绘制耗时函数。

    工具Layout Inspector
    DecorView开始。content往下才是自己写的布局。
    image.png

    重复的布局使用include。
    一个布局+到另一个上,如果加上以后,有两一样的ViewGroup,可以把被加的顶层控件的ViewGroup换成merge
    ViewStub:失败提示框等。需要展示的时候才创建,放在那不占位置。

    过度渲染

    一块区域内(一个像素),如果被绘制了好几次,就是过度渲染。
    过度绘制不可避免,但是应该尽量的减少。
    手机->开发者模式->GPU 过度绘制 打开以后能看到不同颜色的块,越红就说明过度绘制的越严重。对于严重的地方得减少过度绘制。

    1.移除布局中不需要的背景。
    2.降低透明度。
    3.使视图层次结构扁平化。

    布局加载优化

    异步加载布局,视情况而用。

    implementation"androidx.asynclayoutinflater:asynclayoutinflater:1.0.0"

    new AsyncLayoutInflater(this)
    .inflate(R.layout.activity_main, null, new AsyncLayoutInflater.OnInflateFinishedListener() {
    @Override
    public void onInflateFinished(@NonNull View view, int resid, @Nullable ViewGroup parent) {
    setContentView(view);
    //......
    }
    });
    收起阅读 »

    iOS性能优化-卡顿

    iOS
    卡顿原因 成像 图像的显示可以简单理解成先经过CPU的计算/排版/编解码等操作,然后交由GPU去完成渲染放入缓冲中,当视频控制器接受到vSync时会从缓冲中读取已经渲染完成的帧并显示到屏幕上。 卡顿原理 iOS手机默认刷新率是60hz,所以GPU渲染只要达...
    继续阅读 »

    卡顿原因


    成像


    图像的显示可以简单理解成先经过CPU的计算/排版/编解码等操作,然后交由GPU去完成渲染放入缓冲中,当视频控制器接受到vSync时会从缓冲中读取已经渲染完成的帧并显示到屏幕上。


    卡顿原理



    iOS手机默认刷新率是60hz,所以GPU渲染只要达到60fps就不会产生卡顿。

    以60fps为例,vSync会每16.67ms发出,如在16.67ms内没有准备好下一帧数据就会使画面停留在上一帧,产生卡顿,例如图中第3帧的渲染。

    解决思路:尽量减小CPU和GPU的资源消耗



    一些概念:

    CPU:负责对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)

    GPU:负责纹理的渲染(将数据渲染到屏幕)

    垂直同步技术:让CPU和GPU在收到vSync信号后再开始准备数据,防止撕裂感和跳帧,通俗来讲就是保证每秒输出的帧数不高于屏幕显示的帧数。

    双缓冲技术:iOS是双缓冲机制,前帧缓存和后帧缓存,cpu计算完GPU渲染后放入缓冲区中,当gpu下一帧已经渲染完放入缓冲区,且视频控制器已经读完前帧,GPU会等待vSync(垂直同步信号)信号发出后,瞬间切换前后帧缓存,并让cpu开始准备下一帧数据

    安卓4.0后采用三重缓冲,多了一个后帧缓冲,可降低连续丢帧的可能性,但会占用更多的CPU和GPU



    卡顿优化-CPU



    • 尽量用轻量级的对象,比如用不到事件处理的地方使用CALayer取代UIView

    • 尽量提前计算好布局(例如cell行高)

    • 不要频繁地调用和调整UIView的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的调用和修改(UIView的显示属性实际都是CALayer的映射,而CALayer本身是没有这些属性的,都是初次调用属性时通过resolveInstanceMethod添加并创建Dictionry保存的,耗费资源)

    • Autolayout会比直接设置frame消耗更多的CPU资源,当视图数量增长时会呈指数级增长

    • 图片的size最好刚好跟UIImageView的size保持一致,减少图片显示时的处理计算

    • 控制一下线程的最大并发数量

    • 尽量把耗时的操作放到子线程

    • 文本处理(尺寸计算、绘制、CoreText和YYText)

      1. 计算文本宽高boundingRectWithSize:options:context: 和文本绘制drawWithRect:options:context:放在子线程操作

      2. 使用CoreText自定义文本空间,在对象创建过程中可以缓存宽高等信息,避免像UILabel/UITextView需要多次计算(调整和绘制都要计算一次),且CoreText直接使用了CoreGraphics占用内存小,效率高。(YYText)



    • 图片处理(解码、绘制)

      图片都需要先解码成bitmap才能渲染到UI上,iOS创建UIImage,不会立刻进行解码,只有等到显示前才会在主线程进行解码,固可以使用Core Graphics中的CGBitmapContextCreate相关操作提前在子线程中进行强制解压缩获得位图

      (YYImage/SDWebImage/kingfisher的对比)
    SDWebImage的使用:
    CGImageRef imageRef = image.CGImage;
    // device color space
    CGColorSpaceRef colorspaceRef = SDCGColorSpaceGetDeviceRGB();
    BOOL hasAlpha = SDCGImageRefContainsAlpha(imageRef);
    // iOS display alpha info (BRGA8888/BGRX8888)
    CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
    bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;

    size_t width = CGImageGetWidth(imageRef);
    size_t height = CGImageGetHeight(imageRef);

    // kCGImageAlphaNone is not supported in CGBitmapContextCreate.
    // Since the original image here has no alpha info, use kCGImageAlphaNoneSkipLast
    // to create bitmap graphics contexts without alpha info.
    CGContextRef context = CGBitmapContextCreate(NULL,
    width,
    height,
    kBitsPerComponent,
    0,
    colorspaceRef,
    bitmapInfo);
    if (context == NULL) {
    return image;
    }

    // Draw the image into the context and retrieve the new bitmap image without alpha
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
    CGImageRef imageRefWithoutAlpha = CGBitmapContextCreateImage(context);
    UIImage *imageWithoutAlpha = [[UIImage alloc] initWithCGImage:imageRefWithoutAlpha scale:image.scale orientation:image.imageOrientation];
    CGContextRelease(context);
    CGImageRelease(imageRefWithoutAlpha);

    return imageWithoutAlpha;

    卡顿优化-GPU



    • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示

    • GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸

    • GPU会将多个视图混合在一起再去显示,混合的过程会消耗CPU资源,尽量减少视图数量和层次

    • 减少透明的视图(alpha<1),不透明的就设置opaque为YES,GPU就不会去进行alpha的通道合成

    • 尽量避免出现离屏渲染



    离屏渲染

    在OpenGL中,GPU有2种渲染方式

    On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作

    Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作


    离屏渲染消耗性能的原因

    需要创建新的缓冲区

    离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕


    哪些操作会触发离屏渲染?



    • 光栅化,layer.shouldRasterize = YES


    • 遮罩,layer.mask


    • 圆角,同时设置layer.masksToBounds = YES、layer.cornerRadius大于0

      考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片


    • 阴影,layer.shadowXXX

      如果设置了layer.shadowPath就不会产生离屏渲染




    卡顿监控


    Xcode自带Instruments


    在开发阶段,可以直接使用Instrument来检测性能问题,Time Profiler查看与CPU相关的耗时操作,Core Animation查看与GPU相关的渲染操作。


    FPS(CADisplayLink)


    正常情况下,App的FPS只要保持在50~60之间,用户就不会感到界面卡顿。通过向主线程添加CADisplayLink我们可以接收到每次屏幕刷新的回调,从而统计出每秒屏幕刷新次数。这种方案最常见,例如YYFPSLabel,且只用了CADisplayLink,实现成本较低,但由于只能在CPU空闲时才去回调,无法精确采集到卡顿时调用栈信息,可以在开发阶段作为辅助手段使用。


    //
    // YYFPSLabel.m
    // YYKitExample
    //
    // Created by ibireme on 15/9/3.
    // Copyright (c) 2015 ibireme. All rights reserved.
    //

    #import "YYFPSLabel.h"
    //#import <YYKit/YYKit.h>
    #import "YYText.h"
    #import "YYWeakProxy.h"

    #define kSize CGSizeMake(55, 20)

    @implementation YYFPSLabel {
    CADisplayLink *_link;
    NSUInteger _count;
    NSTimeInterval _lastTime;
    UIFont *_font;
    UIFont *_subFont;

    NSTimeInterval _llll;
    }

    - (instancetype)initWithFrame:(CGRect)frame {
    if (frame.size.width == 0 && frame.size.height == 0) {
    frame.size = kSize;
    }
    self = [super initWithFrame:frame];

    self.layer.cornerRadius = 5;
    self.clipsToBounds = YES;
    self.textAlignment = NSTextAlignmentCenter;
    self.userInteractionEnabled = NO;
    self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];

    _font = [UIFont fontWithName:@"Menlo" size:14];
    if (_font) {
    _subFont = [UIFont fontWithName:@"Menlo" size:4];
    } else {
    _font = [UIFont fontWithName:@"Courier" size:14];
    _subFont = [UIFont fontWithName:@"Courier" size:4];
    }
    // 创建CADisplayLink并添加到主线程的RunLoop中
    _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    return self;
    }

    - (void)dealloc {
    [_link invalidate];
    }

    - (CGSize)sizeThatFits:(CGSize)size {
    return kSize;
    }

    //刷新回调时去计算fps
    - (void)tick:(CADisplayLink *)link {
    if (_lastTime == 0) {
    _lastTime = link.timestamp;
    return;
    }

    _count++;
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
    _lastTime = link.timestamp;
    float fps = _count / delta;
    _count = 0;

    CGFloat progress = fps / 60.0;
    UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];

    NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
    [text yy_setColor:color range:NSMakeRange(0, text.length - 3)];
    [text yy_setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
    text.yy_font = _font;
    [text yy_setFont:_subFont range:NSMakeRange(text.length - 4, 1)];

    self.attributedText = text;
    }

    @end

    RunLoop


    关于RunLoop,推荐参考深入理解RunLoop,这里只列出其简化版的状态。



    经典图片
    __CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled)

    // 2.RunLoop 即将触发 Timer 回调。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
    // 3.RunLoop 即将触发 Source0 (非port) 回调。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
    // 4.RunLoop 触发 Source0 (非port) 回调。
    sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle)
    // 5.执行被加入的block等Source1事件
    __CFRunLoopDoBlocks(runloop, currentMode);

    // 6.RunLoop 的线程即将进入休眠(sleep)。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);

    // 7.调用 mach_msg 等待接受 mach_port 的消息。线程将进入休眠, 直到被下面某一个事件唤醒。
    __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort)


    // 进入休眠


    // 8.RunLoop 的线程刚刚被唤醒了。
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting

    // 9.1.如果一个 Timer 到时间了,触发这个Timer的回调
    __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())

    // 9.2.如果有dispatch到main_queue的block,执行bloc
    __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);

    // 9.3.如果一个 Source1 (基于port) 发出事件了,处理这个事件
    __CFRunLoopDoSource1(runloop, currentMode, source1, msg);

    // 10.RunLoop 即将退出
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);


    由于source0处理的是app内部事件,包括UI事件,所以可知处理事件主要是在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之间。我们可以创建一个子线程去监听主线程状态变化,通过dispatch_semaphore在主线程进入状态时发送信号量,子线程设置超时时间循环等待信号量,若超过时间后还未接收到主线程发出的信号量则可判断为卡顿,保存响应的调用栈信息去进行分析。线上卡顿的收集多采用这种方式,可将卡顿信息上传至服务器且用户无感知。


    #pragma mark - 注册RunLoop观察者

    //在主线程注册RunLoop观察者
    - (void)registerMainRunLoopObserver
    {
    //监听每个步凑的回调
    CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
    self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
    kCFRunLoopAllActivities,
    YES,
    0,
    &runLoopObserverCallBack,
    &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
    }

    //观察者方法
    static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
    {
    self.runLoopActivity = activity;
    //触发信号,说明开始执行下一个步骤。
    if (self.semaphore != nil)
    {
    dispatch_semaphore_signal(self.semaphore);
    }
    }

    #pragma mark - RunLoop状态监测

    //创建一个子线程去监听主线程RunLoop状态
    - (void)createRunLoopStatusMonitor
    {
    //创建信号
    self.semaphore = dispatch_semaphore_create(0);
    if (self.semaphore == nil)
    {
    return;
    }

    //创建一个子线程,监测Runloop状态时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^
    {
    while (YES)
    {
    //如果观察者已经移除,则停止进行状态监测
    if (self.runLoopObserver == nil)
    {
    self.runLoopActivity = 0;
    self.semaphore = nil;
    return;
    }

    //信号量等待。状态不等于0,说明状态等待超时
    //方案一->设置单次超时时间为500毫秒
    long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC));
    if (status != 0)
    {
    if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting)
    {
    ...
    //发生超过500毫秒的卡顿,此时去记录调用栈信息
    }
    }
    /*
    //方案二->连续5次卡顿50ms上报
    long status = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
    if (status != 0)
    {
    if (!observer)
    {
    timeoutCount = 0;
    semaphore = 0;
    activity = 0;
    return;
    }

    if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
    {
    if (++timeoutCount < 5)
    continue;
    //保存调用栈信息
    }
    }
    timeoutCount = 0;
    */
    }
    });
    }


    子线程Ping


    根据卡顿发生时,主线程无响应的原理,创建一个子线程循环去Ping主线程,Ping之前先设卡顿置标志为True,再派发到主线程执行设置标志为False,最后子线程在设定的阀值时间内休眠结束后判断标志来判断主线程有无响应。该方法的监控准确性和性能损耗与ping频率成正比。

    代码部分来源于ANREye

    private class AppPingThread: Thread {


    private let semaphore = DispatchSemaphore(value: 0)
    //判断主线程是否卡顿的标识
    private var isMainThreadBlock = false

    private var threshold: Double = 0.4

    fileprivate var handler: (() -> Void)?

    func start(threshold:Double, handler: @escaping AppPingThreadCallBack) {
    self.handler = handler
    self.threshold = threshold
    self.start()
    }

    override func main() {

    while self.isCancelled == false {
    self.isMainThreadBlock = true
    //主线程去重置标识
    DispatchQueue.main.async {
    self.isMainThreadBlock = false
    self.semaphore.signal()
    }

    Thread.sleep(forTimeInterval: self.threshold)
    //若标识未重置成功则说明再设置的阀值时间内主线程未响应,此时去做响应处理
    if self.isMainThreadBlock {
    //采集卡顿调用栈信息
    self.handler?()
    }

    _ = self.semaphore.wait(timeout: DispatchTime.distantFuture)
    }
    }


    }

    参考文章:

    iOS 保持界面流畅的技巧

    屏幕成像原理

    iOS 性能优化总结

    质量监控-卡顿检测



    链接:https://www.jianshu.com/p/4151e4def785

    收起阅读 »

    iOS 简单监测iOS卡顿的demo

    iOS
    本文的demo代码也会更新到github上。 做这个demo思路来源于微信team的:微信iOS卡顿监控系统。 主要思路:通过监测Runloop的kCFRunLoopAfterWaiting,用一个子线程去检查,一次循环是否时间太长。 其中主要涉及到了runl...
    继续阅读 »

    本文的demo代码也会更新到github上。


    做这个demo思路来源于微信team的:微信iOS卡顿监控系统

    主要思路:通过监测Runloop的kCFRunLoopAfterWaiting,用一个子线程去检查,一次循环是否时间太长。

    其中主要涉及到了runloop的原理。关于整个原理:深入理解RunLoop讲解的比较仔细。

    以下就是runloop大概的运行方式:

      /// 1. 通知Observers,即将进入RunLoop
    /// 此处有Observer会创建AutoreleasePool: _objc_autoreleasePoolPush();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopEntry);
    do {

    /// 2. 通知 Observers: 即将触发 Timer 回调。
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeTimers);
    /// 3. 通知 Observers: 即将触发 Source (非基于port的,Source0) 回调。
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeSources);
    __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

    /// 4. 触发 Source0 (非基于port的) 回调。
    __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);

    /// 5. GCD处理main block
    __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__(block);

    /// 6. 通知Observers,即将进入休眠
    /// 此处有Observer释放并新建AutoreleasePool: _objc_autoreleasePoolPop(); _objc_autoreleasePoolPush();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopBeforeWaiting);

    /// 7. sleep to wait msg.
    mach_msg() -> mach_msg_trap();


    /// 8. 通知Observers,线程被唤醒
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopAfterWaiting);

    /// 9. 如果是被Timer唤醒的,回调Timer
    __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__(timer);

    /// 9. 如果是被dispatch唤醒的,执行所有调用 dispatch_async 等方法放入main queue 的 block
    __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(dispatched_block);

    /// 9. 如果如果Runloop是被 Source1 (基于port的) 的事件唤醒了,处理这个事件
    __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);


    } while (...);

    /// 10. 通知Observers,即将退出RunLoop
    /// 此处有Observer释放AutoreleasePool: _objc_autoreleasePoolPop();
    __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__(kCFRunLoopExit);
    }

    其中UI主要集中在__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__(source0);

    __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__(source1);之前。

    获取kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting的状态就可以知道是否有卡顿的情况。


    NSTimer的实现


    具体代码如下:


    //
    // MonitorController.h
    // RunloopMonitorDemo
    //
    // Created by game3108 on 16/4/13.
    // Copyright © 2016年 game3108. All rights reserved.
    //

    #import <Foundation/Foundation.h>

    @interface MonitorController : NSObject
    + (instancetype) sharedInstance;
    - (void) startMonitor;
    - (void) endMonitor;
    - (void) printLogTrace;
    @end

    //
    // MonitorController.m
    // RunloopMonitorDemo
    //
    // Created by game3108 on 16/4/13.
    // Copyright © 2016年 game3108. All rights reserved.
    //

    #import "MonitorController.h"
    #include <libkern/OSAtomic.h>
    #include <execinfo.h>

    @interface MonitorController(){
    CFRunLoopObserverRef _observer;
    double _lastRecordTime;
    NSMutableArray *_backtrace;
    }

    @end

    @implementation MonitorController

    static double _waitStartTime;

    + (instancetype) sharedInstance{
    static dispatch_once_t once;
    static id sharedInstance;
    dispatch_once(&once, ^{
    sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
    }

    - (void) startMonitor{
    [self addMainThreadObserver];
    [self addSecondaryThreadAndObserver];
    }

    - (void) endMonitor{
    if (!_observer) {
    return;
    }
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    CFRelease(_observer);
    _observer = NULL;
    }

    #pragma mark printLogTrace
    - (void)printLogTrace{
    NSLog(@"====================堆栈\n %@ \n",_backtrace);
    }

    #pragma mark addMainThreadObserver
    - (void) addMainThreadObserver {
    dispatch_async(dispatch_get_main_queue(), ^{
    //建立自动释放池
    @autoreleasepool {
    //获得当前thread的Run loop
    NSRunLoop *myRunLoop = [NSRunLoop currentRunLoop];

    //设置Run loop observer的运行环境
    CFRunLoopObserverContext context = {0, (__bridge void *)(self), NULL, NULL, NULL};

    //创建Run loop observer对象
    //第一个参数用于分配observer对象的内存
    //第二个参数用以设置observer所要关注的事件,详见回调函数myRunLoopObserver中注释
    //第三个参数用于标识该observer是在第一次进入run loop时执行还是每次进入run loop处理时均执行
    //第四个参数用于设置该observer的优先级
    //第五个参数用于设置该observer的回调函数
    //第六个参数用于设置该observer的运行环境
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &myRunLoopObserver, &context);

    if (_observer) {
    //将Cocoa的NSRunLoop类型转换成Core Foundation的CFRunLoopRef类型
    CFRunLoopRef cfRunLoop = [myRunLoop getCFRunLoop];
    //将新建的observer加入到当前thread的run loop
    CFRunLoopAddObserver(cfRunLoop, _observer, kCFRunLoopDefaultMode);
    }
    }
    });
    }

    void myRunLoopObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    switch (activity) {
    //The entrance of the run loop, before entering the event processing loop.
    //This activity occurs once for each call to CFRunLoopRun and CFRunLoopRunInMode
    case kCFRunLoopEntry:
    NSLog(@"run loop entry");
    break;
    //Inside the event processing loop before any timers are processed
    case kCFRunLoopBeforeTimers:
    NSLog(@"run loop before timers");
    break;
    //Inside the event processing loop before any sources are processed
    case kCFRunLoopBeforeSources:
    NSLog(@"run loop before sources");
    break;
    //Inside the event processing loop before the run loop sleeps, waiting for a source or timer to fire.
    //This activity does not occur if CFRunLoopRunInMode is called with a timeout of 0 seconds.
    //It also does not occur in a particular iteration of the event processing loop if a version 0 source fires
    case kCFRunLoopBeforeWaiting:{
    _waitStartTime = 0;
    NSLog(@"run loop before waiting");
    break;
    }
    //Inside the event processing loop after the run loop wakes up, but before processing the event that woke it up.
    //This activity occurs only if the run loop did in fact go to sleep during the current loop
    case kCFRunLoopAfterWaiting:{
    _waitStartTime = [[NSDate date] timeIntervalSince1970];
    NSLog(@"run loop after waiting");
    break;
    }
    //The exit of the run loop, after exiting the event processing loop.
    //This activity occurs once for each call to CFRunLoopRun and CFRunLoopRunInMode
    case kCFRunLoopExit:
    NSLog(@"run loop exit");
    break;
    /*
    A combination of all the preceding stages
    case kCFRunLoopAllActivities:
    break;
    */
    default:
    break;
    }
    }

    #pragma mark addSecondaryThreadAndObserver
    - (void) addSecondaryThreadAndObserver{
    NSThread *thread = [self secondaryThread];
    [self performSelector:@selector(addSecondaryTimer) onThread:thread withObject:nil waitUntilDone:YES];
    }

    - (NSThread *)secondaryThread {
    static NSThread *_secondaryThread = nil;
    static dispatch_once_t oncePredicate;
    dispatch_once(&oncePredicate, ^{
    _secondaryThread =
    [[NSThread alloc] initWithTarget:self
    selector:@selector(networkRequestThreadEntryPoint:)
    object:nil];
    [_secondaryThread start];
    });
    return _secondaryThread;
    }

    - (void)networkRequestThreadEntryPoint:(id)__unused object {
    @autoreleasepool {
    [[NSThread currentThread] setName:@"monitorControllerThread"];
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    [runLoop addPort:[NSMachPort port] forMode:NSRunLoopCommonModes];
    [runLoop run];
    }
    }

    - (void) addSecondaryTimer{
    NSTimer *myTimer = [NSTimer timerWithTimeInterval:0.5 target:self selector:@selector(timerFired:) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:myTimer forMode:NSDefaultRunLoopMode];
    }

    - (void)timerFired:(NSTimer *)timer{
    if ( _waitStartTime < 1 ){
    return;
    }
    double currentTime = [[NSDate date] timeIntervalSince1970];
    double timeDiff = currentTime - _waitStartTime;
    if (timeDiff > 2.0){
    if (_lastRecordTime - _waitStartTime < 0.001 && _lastRecordTime != 0){
    NSLog(@"last time no :%f %f",timeDiff, _waitStartTime);
    return;
    }
    [self logStack];
    _lastRecordTime = _waitStartTime;
    }
    }

    - (void)logStack{
    void* callstack[128];
    int frames = backtrace(callstack, 128);
    char **strs = backtrace_symbols(callstack, frames);
    int i;
    _backtrace = [NSMutableArray arrayWithCapacity:frames];
    for ( i = 0 ; i < frames ; i++ ){
    [_backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
    }
    free(strs);
    }

    @end

    主要内容是首先在主线程注册了runloop observer的回调myRunLoopObserver

    每次小循环都会记录一下kCFRunLoopAfterWaiting的时间_waitStartTime,并且在kCFRunLoopBeforeWaiting制空。


    另外开了一个子线程并开启他的runloop(模仿了AFNetworking的方式),并加上一个timer每隔1秒去进行监测。


    如果当前时长与_waitStartTime差距大于2秒,则认为有卡顿情况,并记录了当前堆栈信息。


    PS:整个demo写的比较简单,最后获取堆栈也仅获取了当前线程的堆栈信息([NSThread callStackSymbols]有同样效果),也在寻找获取所有线程堆栈的方法,欢迎指点一下。




    更新:


    了解到 plcrashreporter (github地址) 可以做到获取所有线程堆栈。




    更新2:


    这篇文章也介绍了监测卡顿的方法:检测iOS的APP性能的一些方法

    通过Dispatch Semaphore保证同步这里记录一下。


    写一个Semaphore版本的代码,也放在github上:


    //
    // SeMonitorController.h
    // RunloopMonitorDemo
    //
    // Created by game3108 on 16/4/14.
    // Copyright © 2016年 game3108. All rights reserved.
    //

    #import <Foundation/Foundation.h>

    @interface SeMonitorController : NSObject
    + (instancetype) sharedInstance;
    - (void) startMonitor;
    - (void) endMonitor;
    - (void) printLogTrace;
    @end
    //
    // SeMonitorController.m
    // RunloopMonitorDemo
    //
    // Created by game3108 on 16/4/14.
    // Copyright © 2016年 game3108. All rights reserved.
    //

    #import "SeMonitorController.h"
    #import <libkern/OSAtomic.h>
    #import <execinfo.h>

    @interface SeMonitorController(){
    CFRunLoopObserverRef _observer;
    dispatch_semaphore_t _semaphore;
    CFRunLoopActivity _activity;
    NSInteger _countTime;
    NSMutableArray *_backtrace;
    }

    @end

    @implementation SeMonitorController

    + (instancetype) sharedInstance{
    static dispatch_once_t once;
    static id sharedInstance;
    dispatch_once(&once, ^{
    sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
    }

    - (void) startMonitor{
    [self registerObserver];
    }

    - (void) endMonitor{
    if (!_observer) {
    return;
    }
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    CFRelease(_observer);
    _observer = NULL;
    }

    - (void) printLogTrace{
    NSLog(@"====================堆栈\n %@ \n",_backtrace);
    }

    static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
    {
    SeMonitorController *instrance = [SeMonitorController sharedInstance];
    instrance->_activity = activity;
    // 发送信号
    dispatch_semaphore_t semaphore = instrance->_semaphore;
    dispatch_semaphore_signal(semaphore);
    }

    - (void)registerObserver
    {
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
    kCFRunLoopAllActivities,
    YES,
    0,
    &runLoopObserverCallBack,
    &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);

    // 创建信号
    _semaphore = dispatch_semaphore_create(0);

    // 在子线程监控时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
    while (YES)
    {
    // 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
    long st = dispatch_semaphore_wait(_semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
    if (st != 0)
    {
    if (_activity==kCFRunLoopBeforeSources || _activity==kCFRunLoopAfterWaiting)
    {
    if (++_countTime < 5)
    continue;
    [self logStack];
    NSLog(@"something lag");
    }
    }
    _countTime = 0;
    }
    });
    }

    - (void)logStack{
    void* callstack[128];
    int frames = backtrace(callstack, 128);
    char **strs = backtrace_symbols(callstack, frames);
    int i;
    _backtrace = [NSMutableArray arrayWithCapacity:frames];
    for ( i = 0 ; i < frames ; i++ ){
    [_backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
    }
    free(strs);
    }

    @end

    用Dispatch Semaphore简化了代码复杂度,更加简洁。


    参考资料


    本文csdn地址

    1.微信iOS卡顿监控系统

    2. iphone——使用run loop对象

    3.深入理解RunLoop

    4.检测iOS的APP性能的一些方法

    5.iOS实时卡顿监控



    链接:https://www.jianshu.com/p/71cfbcb15842

    收起阅读 »

    iOS 调试:通过 Safari/Chrome 调试 WebView

    iOS 调试:通过 Safari/Chrome 调试 WebView主要汇总通过 Safari 和 Chrome 调试网页的步骤Safari 调试 WebView1、真机/模拟器开启 Safari 调试开关如果需要远程调试iOS Safari,必须启用Web检...
    继续阅读 »

    iOS 调试:通过 Safari/Chrome 调试 WebView

    主要汇总通过 Safari 和 Chrome 调试网页的步骤

    Safari 调试 WebView

    1、真机/模拟器开启 Safari 调试开关

    如果需要远程调试iOS Safari,必须启用Web检查功能

    • 设置 -> Safari -> 高级 -> 启动”Web检查“



    2、Safari 开启调试模式

    • Safari浏览器 -> 偏好设置 -> 高级 -> 勾选“在菜单栏中显示开发菜单”



    3、开始调试 WebView

    • 将手机通过数据线连接到mac上

    • 打开 Safari 浏览器,运行手机 app 中的 Web 界面

    • 在 Safari -> 开发 中选择连接的手机,并找到调试的网页




    Chrome 调试 WebView

    1、准备工作

    • 安装部署ios-webkit-debug-proxy,在终端中输入
    brew install ios-webkit-debug-proxy
    • 启动 proxy,在终端输入以下命令
    ios_webkit_debug_proxy -f chrome-devtools://devtools/bundled/inspector.html

    运行结果如下所示



    2、调试

    • 在 Chrome 中打开 localhost:9221 ,可以看到当前已连接的设备列表




    在app中打开Web页面,并在Chrome中点击local进入新页面,并右键转到该连接的页面





    最后在Web页面中,右键,选择检查即可






    作者:Style_月月
    链接:https://www.jianshu.com/p/99b52270c59d

    收起阅读 »

    性能优化面试官想听的是什么?别再说那些老掉牙的性能优化了

    网上性能优化的文章太多了,都说如何如何请求优化代码优化之类的,所有人都知道的事,而且实际工作中根本不可能每个项目都用到那些全部,而是应该对我们的项目有针对性的优化,你说是吗? 比如 说一下前端性能优化? 你平时是怎么做性能优化的? 等等类似这样的问题,不过就是...
    继续阅读 »

    网上性能优化的文章太多了,都说如何如何请求优化代码优化之类的,所有人都知道的事,而且实际工作中根本不可能每个项目都用到那些全部,而是应该对我们的项目有针对性的优化,你说是吗?


    比如


    说一下前端性能优化?


    你平时是怎么做性能优化的?


    等等类似这样的问题,不过就是换汤不换药罢了


    好吧,先上药


    这性能优化呢,它是一个特别大的方向,因为每个项目可能优化的点都不一样,每一种框架或者每一种客户端可以优化的点也都不一样


    总的来说,现在B/S架构下都是前端向后端请求,后端整理好数据给客户端返回,然后客户端再进行数据处理、到渲染将界面展示出来,这么一个大致流程


    那我们优化就是要基于这一过程,说白了我们能够优化的点,就只有两个大的方向


    一是更快的网络通信,就是客户端和服务端之间,请求或响应时 数据在路上的时间让它更快


    二是更快的数据处理,指的是



    • 服务器接到请求之后,更快的整理出客户端需要的数据

    • 客户端收到响应的数据后,更快的给用户展示 以及 交互时更快的处理


    然后!开始blablabla.....




    更快网络通信方面比如:CDN做全局负载均衡、CDN缓存、域名分片、资源合并、雪碧图、字体图标、http缓存,以减少请求;还有gzip/br压缩、代码压缩、减少头信息、减少Cookie、使用http2、用jpg/webp、去除元数据等等,blablabla.....


    更快数据处理方面比如:SSR、SSG、预解析、懒加载、按需引入、按需加载、CSS放上面、JS放下面、语义化标签、动画能用CSS就不用JS、事件委托、减少重排、等等代码优化,blablabla.....




    .....


    请直接把上面的总结成一句话 给面试官


    请求优化、代码优化、打包优化都是常规操作,像雅虎34条军规,都知道的事就不用说了


    因为每个项目优化的点可能都不一样,所以优化主要 还是根据自己的项目来


    要么跟人家聊一下框架优化,深入原理也很不错


    具体要优化什么主要还是看浏览器(Chrome为例)开发者工具里的 LighthousePerformance


    image.png


    Lighthouse 是生成性能分析报告。可以看到每一项的数据和评分和建议优化的点,比如哪里图片过大


    Performance 是运行时数据,可以看到更多细节数据。比如阻塞啊,重排重绘啊,都会体现出来,够不够细节


    然后再去根据这些报告和性能指标体现出来的情况,有针对性的去不断优化我们的项目


    Lighthouse


    直接在Chrome开发者工具中打开


    或者 Node 12.x或以上的版本可以直接安装在本地


    npm install -g lighthouse

    安装好后,比如生成掘金的性能分析报告,一句代码就够了,然后就会生成一个html文件


    lighthouse https://juejin.cn/

    不管是浏览器还是安装本地的,生成好的报告都是长的一模一样的,一个英文的html文件,翻译了一张给大家看看,如图


    image.png


    如图,分析报告内容一共五个大项,每一项满分100分,然后下面是再把每项分别展开说明


    还是看一下英文版吧,一个程序员必须要养成这个习惯


    image.png


    如图,五项分别是



    • Performance:这个又分为三块性能指标可优化的项和手动诊断,看这一块就可以我们就可以优化很多东西了

    • Accessibility:无障碍功能分析。比如前景色和背景色没有足够对比度、图片有没有alt属性、a链接有没有可识别名称等等

    • Best Practices:最佳实践策略。比如图片纵横比不正确,控制台有没有报错信息等

    • SEO:有没有SEO搜索引擎优化的一些东西

    • PWA:官方说法是衡量站点是否快速、可靠和可安装。在国内浏览器内核不统一,小程序又这么火,所以好像没什么落地的场景


    然后我们知道了这么多信息,是不是就可以对我们的项目诊断和针对性的优化了呢


    是不是很棒


    image.png


    Performance


    如果说 Lighthouse 是开胃菜,那 Performance 就是正餐了


    它记录了网站运行过程中性能数据。我们回放整个页面执行过程的话,就可以定位和诊断每个时间段内页面运行情况,不过它没有性能评分,也没有优化建议,只是将采集到的数据按时间线的方式展现


    打开 Performance,勾选 Memory,点击左边的 Record 开始录制,然后执行操作或者刷新页面,然后再点一下(Stop)就结束录制,生成数据


    image.png


    如图


    image.png


    概况面板


    里面有页面帧速(FPS)、白屏时间、CPU资源消耗、网络加载情况、V8内存使用量(堆)等等,按时间顺序展示。


    那么怎么看这个图表找到可能存在问题的地方呢




    • 如果FPS(看图右上角)图表上出现红色块,就表示红色块附近渲染出一帧的时间太长了,就有可能导致卡顿




    • 如果CPU图形占用面积太大,表示CPU使用率高,就可能是因为某个JS占用太多主线程时间,阻塞其他任务执行




    • 如果V8的内存使用量一直在增加,就可能因为某种原因导致内存泄露



      • 一次查看内存占用情况后,看当前内存占用趋势图,走势呈上升趋势,可以认为存在内存泄露

      • 多次查看内存占用情况后截图对比,比较每次内存占用情况,如果呈上升趋势,也可以认为存在内存泄露




    通过概览面板定位到可能存在问题的时间节点后,怎么拿到更进一步的数据来分析导致该问题的直接原因呢


    就是点击时间线上有问题的地方,然后这一块详细内容就会显示在性能面板中


    性能面板


    比如我们点击时间线上的某个位置(如红色块),性能面板就会显示该时间节点内的性能数据,如图


    image.png


    性能面板上会列出很多性能指标的项,图中左边,比如



    • Main 指标:是渲染主线程的任务执行记录

    • Timings 指标:记录如FP、FCP、LCP等产生一些关键时间点的数据信息(下面有介绍)

    • Interactions 指标:记录用户交互操作

    • Network 指标:是页面每个请求所耗时间

    • Compositor 指标:是合成线程的任务执行记录

    • GPU 指标:是GPU进程的主线程的任务执行记录

    • Chrome_ChildIOThread 指标:是IO线程的任务执行记录,里面有用户输入事件,网络事件,设备相关等事件

    • Frames 指标:记录每一帧的时间、图层构造等信息

    • .......


    Main 指标


    性能指标项有很多,而我使用的时候多数时间都是分析Main指标,如图


    image.png


    上面第一行灰色的,写着 Task 的,一个 Task 就是一个任务


    下面黄色紫色的都是啥呢,那是一个任务里面的子任务


    我们放大,举个例子


    image.png


    Task 是一个任务,下面的就是 Task 里面的子任务,这个图用代码表示就是


    function A(){
    A1()
    A2()
    }
    function Task(){
    A()
    B()
    }
    Task()

    是不是就好理解得多了


    所以我们就可以选中有问题的,比如标红的 Task ,然后放大(滑动鼠标就可放大),看里面具体的耗时点


    比如都做了哪些操作,哪些函数耗时了多少,代码有压缩的话看到的就是压缩后的函数名。然后我们点击一下某个函数,在面板最下面,就会出现代码的信息,是哪个函数,耗时多少,在哪个文件上的第几行等。这样我们就很方便地定位到耗时函数了


    还可以横向切换 tab ,看它的调用栈等情况,更方便地找到对应代码


    具体大家可以试试~


    Timings 指标


    Timings 指标也需要注意,如图


    image.png


    它上面的FP、FCP、DCL、L、LCP这些都是个啥呢


    别着急


    上面说了 Timings 表示一些关键时间点的数据信息,那么表示哪些时间呢,怎么表示的呢?




    • FP表示首次绘制。记录页面第一次绘制像素的时间




    • FCP表示首次内容绘制(只有文本、图片(包括背景图)、非白色的canvas或SVG时才被算作FCP)




    • LCP最大内容绘制,是代表页面的速度指标。记录视口内最大元素绘制时间,这个会随着页面渲染变化而变化




    • FID首次输入延迟,代表页面交互体验的指标。记录FCP和TTI之间用户首次交互的时间到浏览器实际能够回应这种互动的时间




    • CLS累计位移偏移,代表页面稳定的指标。记录页面非预期的位移,比如渲染过程中插入一张图片或者点击按钮动态插入一段内容等,这时候就会触发位移




    • TTI首次可交互时间。指在视觉上已经渲染了,完全可以响应用户的输入了。是衡量应用加载所需时间并能够快速响应用户交互的指标。与FMP一样,很难规范化适用于所有网页的TTI指标定义




    • DCL: 表示HTML加载完成时间


      注意:DCL和L表示的时间在 Performance 和 NetWork 中是不同的,因为 Performance 的起点是点击录制的时间,Network中起点时间是 fetchStart 时间(检查缓存之前,浏览器准备好使用http请求页面文档的时间)




    • L表示页面所有资源加载完成时间




    • TBT阻塞总时间。记录FCP到TTI之间所有长任务的阻塞时间总和




    • FPS每秒帧率。表示每秒钟画面更新次数,现在大多数设备是60帧/秒




    • FMP首次有意义的绘制。是页面主要内容出现在屏幕上的时间,这是用户感知加载体验的主要指标。有点抽象,因为目前没有标准化的定义。因为很难用通用的方式来确定各种类型的页面的关键内容




    • FCI首次CPU空闲时间。表示网页已经满足了最小程度的与用户发生交互行为的时间




    好了,然后根据指标体现出来的问题,有针对性的优化就好


    结语


    点赞支持、手留余香、与有荣焉


    感谢你能看到这里,加油哦!


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

    收起阅读 »

    微信小程序中wxs文件的妙用

    wxs文件是小程序中的逻辑文件,它和wxml结合使用。 不同于js, wxs可以直接作用到视图层,而不需要进行视图层和逻辑层的setData数据交互; 因为这个特性,wxs非常适合应用于优化小程序的频繁交互操作中; 应用 过滤器 在IOS环境中wxs的运行...
    继续阅读 »

    wxs文件是小程序中的逻辑文件,它和wxml结合使用。

    不同于js, wxs可以直接作用到视图层,而不需要进行视图层和逻辑层的setData数据交互;

    因为这个特性,wxs非常适合应用于优化小程序的频繁交互操作中;



    应用


    过滤器



    在IOS环境中wxs的运行速度要远高于js,在android中两者表现相当。

    使用wxs作为过滤器也可以一定幅度提升性能;让我们来看一个过滤器来了解其语法。



    wxs文件:


    var toDecimal2 = function (x) {
    var f = parseFloat(x);
    if (isNaN(f)) {
    return '0.00'
    }
    var f = Math.round(x * 100) / 100;
    var s = f.toString();
    var rs = s.indexOf('.');
    if (rs < 0) {
    rs = s.length;
    s += '.';
    }
    while (s.length <= rs + 2) {
    s += '0';
    }
    return s;
    }
    module.exports = toDecimal2

    上面的代码实现了数字保留两位小数的功能。


    wxml文件:


    <wxs src="./filter.wxs" module="filter"></wxs>
    <text>{{filter(1)}}</text>

    基本语法:在视图文件中通过wxs标签引入,module值是自定义命名,之后在wxml中可以通过filter调用方法



    上面的代码展示了 wxs的运行逻辑,让我们可以像函数一样调用wxs中的方法;

    下面再看一下wxs针对wxml页面事件中的表现。



    拖拽



    使用交互时(拖拽、上下滑动、左右侧滑等)如果依靠js逻辑层,会需要大量、频繁的数据通信。卡顿是不可避免的;

    使用wxs文件替代交互,不需要频繁使用setData导致实时大量的数据通信,从而节省性能。



    下面展示一个拖拽例子


    wxs文件:


    function touchstart(event) {
    var touch = event.touches[0] || event.changedTouches[0]
    startX = touch.pageX
    startY = touch.pageY
    }

    事件参数event和js中的事件event内容中touches和changedTouches属性一致


    function touchmove(event, ins) {
    var touch = event.touches[0] || event.changedTouches[0]
    ins.selectComponent('.div').setStyle({
    left: startX - touch.pageX + 'px',
    top: startY - touch.pageY + 'px'
    })
    }

    ins(第二个参数)为触发事件的视图层wxml上下文。可以查找页面所有元素并设置style,class(足够完成交互效果)



    注意:在参数event中同样有一个上下文实例instance;

    event中的实例instance作用范围是触发事件的元素内,而事件的ins参数作用范围是触发事件的组件内。



    module.exports = {
    touchstart: touchstart,
    touchmove: touchmove,
    }

    最后将方法抛出去,给wxml文件引用。


    wxml文件


    <wxs module="action" src="./movable.wxs"></wxs> 
    <view class="div" bindtouchstart="{{action.touchstart}}" bindtouchmove="{{action.touchmove}}"></view>


    上面的例子,解释了事件的基本交互用法。



    文件之中相互传参



    在事件交互中,少不了需要各个文件之中传递参数。 下面是比较常用的几种



    wxs传参到js逻辑层


    wxs文件中:


    var dragStart = function (e, ins) {
    ins.callMethod('callback','sldkfj')
    }

    js文件中:


    callback(e){
    console.log(e)
    }
    // sldkfj


    使用callMethod方法,可以执行js中的callback方法。也可以实现传参;



    js逻辑层传参到wxs文件


    js文件中:


    handler(e){
    this.setData({a:1})
    }

    wxml文件:


    <wxs module="action" src="./movable.wxs"></wxs> 
    <view change:prop="{{action.change}}" prop="{{a}}"></view>

    wxs文件中:


    change(newValue,oldValue){}

    js文件中的参数传递到wxs需要通过wxml文件中转。

    js文件触发handler事件,改变a的值之后,最新的a传递到wxml中。

    wxml中prop改变会触发wxs中的change事件。change中则会接收到最新prop值


    wxs中获取dataset(wxs中获取wxml数据)


    wxs中代码


    var dragStart = function (e) {
    var index = e.currentTarget.dataset.index;
    var index = e.instance.getDataset().index;
    }

    上面有提到e.instance是当前触发事件的元素实例。

    所以e.instance.getDataset()获取的是当前触发事件的dataset数据集


    注意点



    wxs和js为不同的两个脚本语言。但是语法和es5基本相同,确又不支持es6语法;
    getState 在多元素交互中非常实用,欢迎探索。



    不知道是否是支持的语法可以跳转官网文档;
    wxs运算符、语句、基础类库、数据类型



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

    收起阅读 »

    使用 Electron 开发桌面应用

    介绍 Electron,官方简介:使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。 出于个人爱好,接触到了Electron,并开始尝试开发一些本地小工具。 以下是对开发过程做的一个经验总结,便于回顾和交流。 使用 下面来构建一...
    继续阅读 »

    介绍



    Electron,官方简介:使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。

    出于个人爱好,接触到了Electron,并开始尝试开发一些本地小工具。

    以下是对开发过程做的一个经验总结,便于回顾和交流。



    使用



    下面来构建一个简单的electron应用。

    应用源码地址:github.com/zhuxingmin/…



    1. 项目初始化



    项目基于 create-react-app@3.3.0 搭建,执行命令生成项目



    // 全局安装 create-react-app
    npm install -g create-react-app

    // 执行命令生成项目
    create-react-app electronApp

    // 安装依赖并启动项目
    yarn && yarn start


    此时启动的只是一个react应用,下一步安装 electron electron-updater electron-builder electron-is-dev等库



    yarn add electron electron-updater electron-builder electron-is-dev

    2. 配置package.json



    安装完项目依赖后,在package.json中添加electron应用相关配置。



    "version": "0.0.1"              // 设置应用版本号 
    "productName": "appName" // 设置应用名称
    "main": "main.js" // 设置应用入口文件
    "homepage": "." // 设置应用根路径


    scripts中添加应用命令,启动以及打包。



    "estart": "electron ."              // 启动
    "package-win": "electron-builder" // 打包 (此处以windows平台为例,故命名为package-win)


    新增build配置项,添加打包相关配置。

    主要有以下几个配置:


    "build": {
    // 自定义appId 一般以安装路径作为id windows下可以在 PowerShell中输入Get-StartApps查看应用id
    "appId": "org.develar.zhuxingmin",
    // 打包压缩 "store" | "normal"| "maximum"
    "compression": "store",
    // nsis安装配置
    "nsis": {
    "oneClick": false, // 一键安装
    "allowToChangeInstallationDirectory": true, // 允许修改安装目录
    // 下面这些配置不常用
    "guid": "haha", // 注册表名字
    "perMachine": true, // 是否开启安装时权限限制(此电脑或当前用户)
    "allowElevation": true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。
    "installerIcon": "xxx.ico", // 安装图标
    "uninstallerIcon": "xxx.ico", //卸载图标
    "installerHeaderIcon": "xxx.ico", // 安装时头部图标
    "createDesktopShortcut": true, // 创建桌面图标
    "createStartMenuShortcut": true, // 创建开始菜单图标
    "shortcutName": "lalala" // 图标名称
    },
    // 应用打包所包含文件
    "files": [
    "build/**/*",
    "main.js",
    "source/*",
    "service/*",
    "static/*",
    "commands/*"
    ],
    // 应用打包地址和输出地址
    "directories": {
    "app": "./",
    "output": "dist"
    },
    // 发布配置 用于配合自动更新
    "publish": [
    {
    // "generic" | "github"
    "provider": "generic", // 静态资源服务器
    "url": "http://你的服务器目录/latest.yml"
    }
    ],
    // 自定义协议 用于唤醒应用
    "protocols": [
    {
    "name": "myProtocol",
    "schemes": [
    "myProtocol"
    ]
    }
    ],
    // windows打包配置
    "win": {
    "icon": "build/fav.ico",
    // 运行权限
    // "requireAdministrator" | "获取管理员权"
    // "highestAvailable" | "最高可用权限"
    "requestedExecutionLevel": "highestAvailable",
    "target": [
    {
    "target": "nsis"
    }
    ]
    },
    },

    3. 编写入口文件 main.js



    众所周知,基于react脚手架搭建的项目,入口文件为index.js,因此在上面配置完成后,我们想要启动electron应用,需要修改项目入口为main.js




    1. 首先在目录下新建main.js文件,并在package.json文件中,修改应用入口字段main的值为main.js

    2. 通过electron提供的BrowserWindow,创建一个窗口实例mainWindow

    3. 通过mainWindow实例方法loadURL, 加载静态资源

    4. 静态资源分两种加载方式:开发和生产;需要通过electron-is-dev判断当前环境;若是开发环境,可以开启调试入口,通过http://localhost:3000/加载本地资源(react项目启动默认地址);若是生产环境,则要关闭调试入口,并通过本地路径找到项目入口文件index.html



    大体代码如下



    const { BrowserWindow } = require("electron");
    const url = require("url");
    const isDev = require('electron-is-dev');
    mainWindow = new BrowserWindow({
    width: 1200, // 初始宽度
    height: 800, // 初始高度
    minWidth: 1200,
    minHeight: 675,
    autoHideMenuBar: true, // 隐藏应用自带菜单栏
    titleBarStyle: false, // 隐藏应用自带标题栏
    resizable: true, // 允许窗口拉伸
    frame: false, // 隐藏边框
    transparent: true, // 背景透明
    backgroundColor: "none", // 无背景色
    show: false, // 默认不显示
    hasShadow: false, // 应用无阴影
    modal: true, // 该窗口是否为禁用父窗口的子窗口
    webPreferences: {
    devTools: isDev, // 是否开启调试功能
    nodeIntegration: true, // 默认集成node环境
    },
    });

    const config = dev
    ? "http://localhost:3000/"
    : url.format({
    pathname: path.join(__dirname, "./build/index.html"),
    protocol: "file:",
    slashes: true,
    });

    mainWindow.loadURL(config);

    4. 项目启动



    项目前置操作完成,运行上面配置的命令来启动electron应用



       // 启动react应用,此时应用运行在"http://localhost:3000/"
    yarn start
    // 再启动electron应用,electron应用会在入口文件`main.js`中通过 mainWindow.loadURL(config) 来加载react应用
    yarn estart


    文件目录





    至此,一个简单的electron应用已经启动,效果图如下(这是示例项目的截图)。



    效果图



    作为一个客户端应用,它的更新与我们的网页开发相比要显得稍微复杂一些,具体将会通过下面一个应用更新的例子来说明。



    5. 应用更新



    electron客户端的更新与网页不同,它需要先下载更新包到本地,然后通过覆盖源文件来达到更新效果。




    首先第一步,安装依赖



    yarn add electron-updater electron-builder
    复制代码


    应用通过electron-updater提供的api,去上文配置的服务器地址寻找并对比latest.yml文件,如果版本号有更新,则开始下载资源,并返回下载进度相关信息。下载完成后可以自动也可以手动提示用户,应用有更新,请重启以完成更新 (更新是可以做到无感的,下载完更新包之后,可以不提示,下次启动客户端时会自动更新)



    // 主进程
    const { autoUpdater } = require("electron-updater");
    const updateUrl = "应用所在的远程服务器目录"
    const message = {
    error: "检查更新出错",
    checking: "正在检查更新……",
    updateAva: "检测到新版本,正在下载……",
    updateNotAva: "现在使用的就是最新版本,不用更新",
    };
    autoUpdater.setFeedURL(updateUrl);
    autoUpdater.on("error", (error) => {
    sendUpdateMessage("error", message.error);
    });
    autoUpdater.on("checking-for-update", () => {
    sendUpdateMessage("checking-for-update", message.checking);
    });
    autoUpdater.on("update-available", (info) => {
    sendUpdateMessage("update-available", message.updateAva);
    });
    autoUpdater.on("update-not-available", (info) => {
    sendUpdateMessage("update-not-available", message.updateNotAva);
    });
    // 更新下载进度事件
    autoUpdater.on("download-progress", (progressObj) => {
    mainWindow.webContents.send("downloadProgress", progressObj);
    });
    autoUpdater.on("update-downloaded", function (
    event,
    releaseNotes,
    releaseName,
    releaseDate,
    updateUrl,
    quitAndUpdate
    ) {
    ipcMain.on("isUpdateNow", (e, arg) => {
    // 接收渲染进程的确认消息 退出应用并更新
    autoUpdater.quitAndInstall();
    });
    //询问是否立即更新
    mainWindow.webContents.send("isUpdateNow");
    });
    ipcMain.on("checkForUpdate", () => {
    //检查是否有更新
    autoUpdater.checkForUpdates();
    });

    function sendUpdateMessage(type, text) {
    // 将更新的消息事件通知到渲染进程
    mainWindow.webContents.send("message", { text, type });
    }

    // 渲染进程
    const { ipcRenderer } = window.require("electron");

    // 发送检查更新的请求
    ipcRenderer.send("checkForUpdate");

    // 设置检查更新的监听频道

    // 监听检查更新事件
    ipcRenderer.on("message", (event, data) => {
    console.log(data)
    });

    // 监听下载进度
    ipcRenderer.on("downloadProgress", (event, data) => {
    console.log("downloadProgress: ", data);
    });

    // 监听是否可以开始更新
    ipcRenderer.on("isUpdateNow", (event, data) => {
    // 用户点击确定更新后,回传给主进程
    ipcRenderer.send("isUpdateNow");
    });


    应用更新的主要步骤




    1. 在主进程中,通过api获取远程服务器上是否有更新包

    2. 对比更新包的版本号来确定是否更新

    3. 对比结果如需更新,则开始下载更新包并返回当前下载进度

    4. 下载完成后,开发者可选择自动提示还是手动提示或者不提醒(应用在下次启动时会自动更新)



    上文演示了在页面上(渲染进程),是如何与主进程进行通信,让主进程去检查更新。

    在实际使用中,如果我们需要用到后台的能力或者原生功能时,主进程与渲染进程的交互必不可少。

    那么他们有哪些交互方式呢?



    在看下面的代码片段之前,可以先了解一下electron主进程与渲染进程
    简单来说就是,通过main.js来执行的都属于主进程,其余皆为渲染进程。


    6. 主进程与渲染进程间的常用交互方式


    // 主进程中使用
    const { ipcMain } = require("electron");

    // 渲染进程中使用
    const { ipcRenderer } = window.require("electron");

    方式一



    渲染进程 发送请求并监听回调频道



    ipcRenderer.send(channel, someRequestParams);
    ipcRenderer.on(`${channel}-reply`, (event, result)=>{
    // 接收到主进程返回的result
    })


    主进程 监听请求并返回结果



    ipcMain.on(channel, (event, someRequestParams) => {
    // 根据someRequestParams,经过操作后得到result
    event.reply(`${channel}-reply`, result)
    })

    方式二



    渲染进程



    const result = await ipcRenderer.invoke(channel, someRequestParams);


    主进程:



    ipcMain.handle(channel, (event, someRequestParams) => {
    // 根据someRequestParams,经过操作后得到result
    return result
    });

    方式三
    以上两种方式均为渲染进程通知主进程, 第三种是主进程通知渲染进程



    主进程



    /*
    * 使用`BrowserWindow`初始化的实例`mainWindow`
    */
    mainWindow.webContents.send(channel, something)


    渲染进程



    ipcRenderer.on(channel, (event, something) => {
    // do something
    })

    上文的应用更新用的就是方式一


    还有其它通讯方式postMessage, sendTo等,可以根据具体场景决定使用何种方式。


    7. 应用唤醒(与其他应用联动)


    electron应用除了双击图标运行之外,还可以通过协议链接启动(浏览器地址栏或者命令行)。这使得我们可以在网页或者其他应用中,以链接的形式唤醒该应用。链接可以携带参数 例:zhuxingmin://?a=1&b=2&c=3 ‘自定义协议名:zhuxingmin’ ‘参数:a=1&b=2&c=3’。


    我们可以通过参数,来使应用跳转到某一页或者让应用做一些功能性动作等等。


    const path = require('path');
    const { app } = require('electron');

    // 获取单实例锁
    const gotTheLock = app.requestSingleInstanceLock();

    // 如果获取失败,证明已有实例在运行,直接退出
    if (!gotTheLock) {
    app.quit();
    }

    const args = [];
    // 如果是开发环境,需要脚本的绝对路径加入参数中
    if (!app.isPackaged) {
    args.push(path.resolve(process.argv[1]));
    }
    // 加一个 `--` 以确保后面的参数不被 Electron 处理
    args.push('--');
    const PROTOCOL = 'zhuxingmin';
    // 设置自定义协议
    app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);

    // 如果打开协议时,没有其他实例,则当前实例当做主实例,处理参数
    handleArgv(process.argv);

    // 其他实例启动时,主实例会通过 second-instance 事件接收其他实例的启动参数 `argv`
    app.on('second-instance', (event, argv) => {
    if (process.platform === 'win32') {
    // Windows 下通过协议URL启动时,URL会作为参数,所以需要在这个事件里处理
    handleArgv(argv);
    }
    });

    // macOS 下通过协议URL启动时,主实例会通过 open-url 事件接收这个 URL
    app.on('open-url', (event, urlStr) => {
    handleUrl(urlStr);
    });

    function handleArgv(argv) {
    const prefix = `${PROTOCOL}:`;
    const offset = app.isPackaged ? 1 : 2;
    const url = argv.find((arg, i) => i >= offset && arg.startsWith(prefix));
    if (url) handleUrl(url);
    }

    function handleUrl(urlStr) {
    // myapp://?a=1&b=2
    let paramArr = urlStr.split("?")[1].split("&");
    const params = {};
    paramArr.forEach((item) => {
    if (item) {
    const [key, value] = item.split("=");
    params[key] = value;
    }
    });
    /**
    {
    a: 1,
    b: 2
    }
    */

    }

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

    收起阅读 »

    H5 性能极致优化

    项目背景 H5 项目是企鹅辅导的核心项目,已迭代四年多,包括了课程详情页/老师详情页/报名页/支付页面等页面,构建产物用于企鹅辅导 APP/H5(微信/QQ/浏览器),迭代过程中了也累积了一些性能问题导致页面加载、渲染速度变慢, 为了提升用户体验,近期启动了“...
    继续阅读 »

    项目背景


    H5 项目是企鹅辅导的核心项目,已迭代四年多,包括了课程详情页/老师详情页/报名页/支付页面等页面,构建产物用于企鹅辅导 APP/H5(微信/QQ/浏览器),迭代过程中了也累积了一些性能问题导致页面加载、渲染速度变慢, 为了提升用户体验,近期启动了“H5 性能优化”项目,针对页面加载速度,渲染速度做了专项优化,下面是对本次优化的总结,包括以下几部分内容。



    1. 性能优化效果展示

    2. 性能指标及数据采集

    3. 性能分析方法及环境准备

    4. 性能优化具体实践


    一、性能指标及数据采集


    企鹅辅导 H5 采用的性能指标包括:


    1.页面加载时间:页面以多快的速度加载和渲染元素到页面上。



    • First contentful paint (FCP): 测量页面开始加载到某一块内容显示在页面上的时间。

    • Largest contentful paint (LCP): 测量页面开始加载到最大文本块内容或图片显示在页面中的时间。

    • DomContentLoaded Event:DOM解析完成时间

    • OnLoad Event:页面资源加载完成时间


    2.加载后响应时间:页面加载和执行js代码后多久能响应用户交互。



    • First input delay (FID): 测量用户首次与网站进行交互(例如点击一个链接、按钮、js自定义控件)到浏览器真正进行响应的时间。


    3.视觉稳定性:页面元素是否会以用户不期望的方式移动,并干扰用户的交互。



    • Cumulative layout shift (CLS): 测量从页面开始加载到状态变为隐藏过程中,发生不可预期的layout shifts的累积分数。


    项目使用了 IMLOG 进行数据上报,ELK 体系进行现网数据监控,Grafana 配置视图,观察现网情况。


    根据指标的数据分布,能及时发现页面数据异常采取措施。


    二、性能分析及环境准备


    现网页面情况:



    可以看到进度条在页面已经展示后还在持续 loading,加载时间长达十几秒,比较影响了用户体验。


    根据 Google 开发文档 对浏览器架构的解释:



    当导航提交完成后,渲染进程开始着手加载资源以及渲染页面。一旦渲染进程“完成”(finished)渲染,它会通过IPC告知浏览器进程(注意这发生在页面上所有帧(frames)的 onload 事件都已经被触发了而且对应的处理函数已经执行完成了的时候),然后UI线程就会停止导航栏上旋转的圈圈



    我们可以知道,进度条的加载时长和 onload 时间密切相关,要想进度条尽快结束就要 减少 onload时长。


    根据现状,使用ChromeDevTool作为基础的性能分析工具,观察页面性能情况


    Network:观察网络资源加载耗时及顺序


    Performace:观察页面渲染表现及JS执行情况


    Lighthouse:对网站进行整体评分,找出可优化项


    下面以企鹅辅导课程详情页为案例进行分析,找出潜在的优化项


    (注意使用Chrome 隐身窗口并禁用插件,移除其他加载项对页面的影响)


    1. Network 分析


    通常进行网络分析需要禁用缓存、启用网络限速(4g/3g) 模拟移动端弱网情况下的加载情况,因为wifi网络可能会抹平性能差距。



    可以看到DOMContentLoaded的时间在 6.03s ,但onload的时间却在 20.92s


    先观察 DOMContentLoaded 阶段,发现最长请求路径在 vendor.js ,JS大小为170kB,花费时间为 4.32s


    继续观察 DOMContentLoaded 到 onload 的这段时间



    可以发现onload事件被大量媒体资源阻塞了,关于 onload 事件的影响因素,可以参考这篇文章


    结论是 浏览器认为资源完全加载完成(HTML解析的资源 和 动态加载的资源)才会触发 onload


    结合上图 可以发现加载了图片、视频、iFrame等资源,阻塞了 onload 事件的触发


    Network 总结



    1. DOM的解析受JS加载和执行的影响,尽量对JS进行压缩、拆分处理(HTTP2下),能减少 DOMContentLoaded 时间

    2. 图片、视频、iFrame等资源,会阻塞 onload 事件的触发,需要优化资源的加载时机,尽快触发onload


    2. Performance 分析


    使用Performance模拟移动端注意手机处理器能力比PC差,所以一般将 CPU 设置为 4x slowdown 或 6x slowdown 进行模拟



    观察几个核心的数据



    1. Web Vitals ( FP / FCP / LCP / Layout Shift ) 核心页面指标 和 Timings 时长


    可以看到 LCP、DCL和 Onload Event 时间较长,且出现了多次 Layout Shift。


    要 LCP 尽量早触发,需要减少页面大块元素的渲染时间,观察 Frames 或ScreenShots 的截图,关注页面的元素渲染情况。


    可以通过在 Experience 行点击Layout Shift ,在 Summary 面板找到具体的偏移内容。




    1. Main Long Tasks 长任务数量和时长


    可以看到页面有大量的Long Tasks需要进行优化,其中couse.js(页面代码)的解析执行时间长达800ms。


    处理Long Tasks,可以在开发环境进行录制,这样在 Main Timeline 能看到具体的代码执行文件和消耗时长。


    Performance 总结



    1. 页面LCP触发时间较晚,且出现多次布局偏移,影响用户体验,需要尽早渲染内容和减少布局偏移

    2. 页面 Long Tasks 较多,需要对 JS进行合理拆分和加载,减少 Long Tasks 数量,特别是 影响 DCL 和 Onload Event 的 Task


    3. Lighthouse 分析


    使用ChromeDevTool 内置 lighthouse 对页面进行跑分



    分数较低,可以看到 Metrics 给出了核心的数据指标,这边显示的是 TTI SI TBT 不合格,LCP 需要提升,FCP 和 CLS 达到了良好的标准,可以查看分数计算标准


    同时 lighthouse 会提供一些 优化建议,在 Oppotunities 和 Diagnostics 项,能看到具体的操作指南,如 图片大小、移除无用JS等,可以根据指南进行项目的优化。


    lighthouse 的评分内容是根据项目整体加载项目进行打分的,审查出的问题同样包含Network、Performance的内容,所以也可以看作是对 Network、Performance问题的优化建议。


    Lighthouse 总结



    1. 根据评分,可以看出 TTI、SI、TBT、LCP这四项指标需要提高,可以参考lighthouse 文档进行优化。

    2. Oppotunities 和 Diagnostics 提供了具体的优化建议,可以参考进行改善。


    4. 环境准备


    刚才是对线上网页就行初步的问题分析,要实际进行优化和观察,需要进行环境的模拟,让优化效果能更真实在测试环境中体现。


    代理使用:whistle、charles、fiddler等


    本地环境、测试环境模拟:nginx、nohost、stke等


    数据上报:IMLOG、TAM、RUM等


    前端代码打包分析:webpack-bundle-analyzer 、rollup-plugin-visualizer等


    分析问题时使用本地代码,本地模拟线上环境验证优化效果,最后再部署到测试环境验证,提高开发效率。


    三、性能优化具体实践


    PART1: 加载时间优化


    Network 中对页面中加载的资源进行分类


    第一部分是影响 DOM解析的JS资源,可以看到这里分类为 关键JS和非关键JS,是根据是否参与首面渲染划分的


    这里的非关键JS我们可以考虑延迟异步加载,关键JS进行拆分优化处理


    1. 关键JS打包优化



    JS 文件数量8个,总体积 460.8kB,最大文件 170KB


    1.1 Splitchunks 的正确配置

    vendor.js 170kB(gzipd) 是所有页面都会加载的公共文件,打包规则是 miniChunks: 3,引用超过3次的模块将被打进这个js




    分析vendor.js的具体构成(上图)


    以string-strip-html.umd.js 为例 大小为34.7KB,占了 vendor.js的 20%体积,但只有一个页面多次使用到了这个包,触发了miniChunks的规则,被打进了vendor.js。


    同理对vendor.js的其他模块进行分析,iosSelect.js、howler.js、weixin-js-sdk等模块都只有3、4个页面/组件依赖,但也同样打进了 vendor.js。


    由上面的分析,我们可以得出结论:不能简单的依靠miniChunks规则对页面依赖模块进行抽离打包,要根据具体情况拆分公共依赖。


    修改后的vendor根据业务具体的需求,提取不同页面和组件都有的共同依赖(imutils/imlog/qqapi)


    vendor: {
    test({ resource }) {
    return /[\\/]node_modules[\\/](@tencent\/imutils|imlog\/)|qqapi/.test(resource);
    },
    name: 'vendor',
    priority: 50,
    minChunks: 1,
    reuseExistingChunk: true,
    },

    而其他未指定的公共依赖,新增一个common.js,将阈值调高到20或更高(当前页面数76),让公共依赖成为大多数页面的依赖,提高依赖缓存利用率,调整完后,vendor.js 的大小减少到 30KB,common.js 大小为42KB


    两个文件加起来大小为 72KB,相对于优化前体积减少了 60%(100KB)


    1.2 公共组件的按需加载


    course.js 101kB (gzipd) 这个文件是页面业务代码的文件



    观察上图,基本都是业务代码,除了一个巨大的** component Icon,占了 25k**,页面文件1/4的体积,但在代码中使用到的 Icon 总共才8个


    分析代码,可以看到这里使用require加载svg,Webpack将require文件夹内的内容一并打包,导致页面 Icon 组件冗余



    如何解决这类问题实现按需加载?


    按需加载的内容应该为独立的组件,我们将之前的单一入口的 ICON 组件(动态dangerouslySetInnerHTML)改成单文件组件模式直接引入使用图标。



    但实际开发中这样会有些麻烦,一般需要统一的 import 路径,指定需要的图标再加载,参考 babel-plugin-import,我们可以配置 babel 的依赖加载路径调整 Icon 的引入方式,这样就实现了图标的按需加载。



    按需加载后,重新编译,查看打包带来的收益,页面的 Icons 组件 stat size 由 74KB 降到了 20KB,体积减少了 70%


    1.3 业务组件的代码拆分 (Code Splitting)


    观察页面,可以看到”课程大纲“、”课程详情“、”购课须知“这三个模块并不在页面的首屏渲染内容里,



    我们可以考虑对页面这几部分组件进行拆分再延迟加载,减少业务代码JS大小和执行时长


    拆分的方式很多,可以使用react-loadable、@loadable/component 等库实现,也可以使用React 官方提供的React.lazy


    拆分后的代码



    代码拆分会导致组件会有渲染的延迟,所以在项目中使用应该综合用户体验和性能再做决定,通过拆分也能使部分资源延后加载优化加载时间。


    1.4 Tree Shaking 优化


    项目中使用了 TreeShaking的优化,用时候要注意 sideEffects 的使用场景,以免打包产物和开发不一致。


    经过上述优化步骤,整体打包内容:



    JS 文件数量6个,总体积 308KB,最大文件体积 109KB


    关键 JS 优化数据对比:



























    文件总体积最大文件体积
    优化前460.8 kb170 kb
    优化后308 kb109 kb
    优化效果总体积减少 50%最大文件体积减少 56%

    2.非关键 JS 延迟加载


    页面中包含了一些上报相关的 JS 如 sentry,beacon(灯塔 SDK)等,对于这类资源,如果在弱网情况,可能会成为影响 DOM 解析的因素


    为了减少这类非关键JS的影响,可以在页面完成加载后再加载非关键JS,如sentry官方也提供了延迟加载的方案


    在项目中还发现了一部分非关键JS,如验证码组件,为了在下一个页面中能利用缓存尽快加载,所以在上一个页面提前加载一次生成缓存



    如果不访问下一个页面,可以认为这是一次无效加载,这类的提前缓存方案反而会影响到页面性能。


    针对这里资源,我们可以使用 Resource Hints,针对资源做 Prefetch 处理


    检测浏览器是否支持 prefech,支持的情况下我们可以创建 Prefetch 链接,不支持就使用旧逻辑直接加载,这样能更大程度保证页面性能,为下一个页面提供提前加载的支持。


    const isPrefetchSupported = () => {
    const link = document.createElement('link');
    const { relList } = link;

    if (!relList || !relList.supports) {
    return false;
    }
    return relList.supports('prefetch');
    };
    const prefetch = () => {
    const isPrefetchSupport = isPrefetchSupported();
    if (isPrefetchSupport) {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.as = type;
    link.href = url;
    document.head.appendChild(link);
    } else if (type === 'script') {
    // load script
    }
    };

    优化效果:非关键JS不影响页面加载




    3.媒体资源加载优化


    3.1 加载时序优化


    可以观察到onload被大量的图片资源和视频资源阻塞了,但是页面上并没有展示对应的图片或视频,这部分内容应该进行懒加载处理。



    处理方式主要是要控制好图片懒加载的逻辑(如 onload 后再加载),可以借助各类 lazyload 的库去实现。 H5项目用的是位置检测(getBoundingClientRect )图片到达页面可视区域再展示。


    但要注意懒加载不能阻塞业务的正常展示,应该做好超时处理、重试等兜底措施


    3.2 大小尺寸优化


    课程详情页 每张详情图的宽为 1715px,以6s为基准(375px)已经是 4x图了,大图片在弱网情况下会影响页面加载和渲染速度



    使用CDN 图床尺寸大小压缩功能,根据不同的设备渲染不同大小的图片调整图片格式,根据网络情况,渲染不同清晰度的图



    可以看到在弱网(移动3G网络)的情况下,同一张图片不同尺寸加载速度最高和最低相差接近6倍,给用户的体验截然不同


    CDN配合业务具体实现:使用 img 标签 srcset/sizes 属性和 picutre 标签实现响应式图片,具体可参考文档


    使用URL动态拼接方式构造url请求,根据机型宽度和网络情况,判断当前图片宽度倍数进行调整(如iphone 1x,ipad 2x,弱网0.5x)


    优化效果:移动端 正常网络情况下图片体积减小 220%、弱网情况下图片体积减小 13倍


    注意实际业务中需要视觉同学参与,评估图片的清晰度是否符合视觉标准,避免反向优化!


    3.3 其他类型资源优化


    iframe


    加载 iframe 有可能会对页面的加载产生严重的影响,在 onload 之前加载会阻塞 onload 事件触发,从而阻塞 loading,但是还存在另一个问题


    如下图所示,页面在已经 onload 的情况下触发 iframe 的加载,进度条仍然在不停的转动,直到 iframe 的内容加载完成。



    可以将iframe的时机放在 onload 之后,并使用setTimeout触发异步加载iframe,可避免iframe带来的loading影响


    数据上报


    项目中使用 image 的数据上报请求,在正常网络情况下可能感受不到对页面性能的影响


    但在一些特殊情况,如其中一个图片请求的耗时特别长就会阻塞页面 onload 事件的触发,延长 loading 时间



    解决上报对性能的影响问题有以下方案



    1. 延迟合并上报

    2. 使用 Beacon API

    3. 使用 post 上报


    H5项目采用了延迟合并上报的方案,业务可根据实际需要进行选择


    优化效果:全部数据上报在onload后处理,避免对性能产生影响。



    字体优化


    项目中可能会包含很多视觉指定渲染的字体,当字体文件比较大的时候,也会影响到页面的加载和渲染,可以使用 fontmin 将字体资源进行压缩,生成精简版的字体文件、


    优化前:20kB => 优化后:14kB



    PART2: 页面渲染优化


    1.直出页面 TTFB 时间优化


    目前我们在STKE部署了直出服务,通过监控发现直出平均耗时在 300+ms


    TTFB时间在 100 ~ 200 之间波动,影响了直出页面的渲染



    通过日志打点、查看 Nginx Accesslog 日志、网关监控耗时,得出以下数据(如图)



    • STKE直出程序耗时是 20ms左右

    • 直出网关NGW -> STKE 耗时 60ms 左右

    • 反向代理网关NGINX -> NGW 耗时 60ms 左右


    登陆 NGW 所在机器,ping STKE机器,有以下数据


    平均时延在 32ms,tcp 三次握手+返回数据(最后一次 ack 时发送数据)= 2个 rtt,约 64ms,和日志记录的数据一致


    查看 NGW 机器所在区域为天津,STKE 机器所在区域为南京,可以初步判断是由机房物理距离导致的网络时延,如下图所示



    切换NGW到南京机器 ping STKE南京的机器,有以下数据:


    同区域机器 ping 的网络时延只有 0.x毫秒,如下图所示:


    综合上述分析,直出页面TTFB时间过长的根本原因是:NGW 网关部署和 Nginx、STKE 不在同一区域,导致网络时延的产生


    解决方案是让网关和直出服务机房部署在同一区域,执行了以下操作:



    • NGW扩容

    • 北极星开启就近访问


    优化前


    优化后


    优化效果如上图:



















    七天网关平均耗时
    优化前153 ms
    优化后31 ms 优化 80%(120 ms)

    2.页面渲染时间优化


    模拟弱网情况(slow 3g)Performance 录制页面渲染情况,从下图Screenshot中可以发现



    1. DOM 开始解析,但页面还未渲染

    2. CSS 文件下载完成后页面才正常渲染


    CSS不会阻塞页面解析,但会阻塞页面渲染,如果CSS文件较大或弱网情况,会影响到页面渲染时间,影响用户体验。


    借助 ChromeDevTool 的 Coverage 工具(More Tools里面),录制页面渲染时CSS的使用率



    发现首屏的CSS使用率才15%,可以考虑对页面首屏的关键CSS进行内联让页面渲染不被CSS阻塞,再把完整CSS加载进来


    实现Critial CSS 的优化可以考虑使用 critters


    优化后效果:


    CSS 资源正在下载时,页面已经能正常渲染显示了,对比优化前,渲染时间上 提升了 1~2 个 css 文件加载的时间。



    3. 页面布局抖动优化


    观察页面的元素变化



    优化前(左图):图标缺失、背景图缺失、字体大小改变导致页面抖动、出现非预期页面元素导致页面抖动


    优化后:内容相对固定, 页面元素出现无突兀感



    主要优化内容:



    1. 确定直出页面元素出现位置,根据直出数据做好布局

    2. 页面小图可以通过base64处理,页面解析的时候就会立即展示

    3. 减少动态内容对页面布局的影响,使用脱离文档流的方式或定好宽高


    四、性能优化效果展示


    优化效果由以下指标量化


    首次内容绘制时间FCP(First Contentful Paint):标记浏览器渲染来自 DOM 第一位内容的时间点


    视窗最大内容渲染时间LCP(Largest Contentful Paint):代表页面可视区域接近完整渲染


    加载进度条时间:浏览器 onload 事件触发时间,触发后导航栏进度条显示完成


    Chrome 模拟器 4G 无缓存对比(左优化前、右优化后)























    首屏最大内容绘制时间进度条加载(onload)时间
    优化前1067 ms6.18s
    优化后31 ms 优化 80%(120 ms)1.19s 优化 81%

    Lighthouse 跑分对比


    优化前


    优化后



    srobot 性能检测一周数据



    srobot 是团队内的性能检测工具,使用TRobot指令一键创建页面健康检测,定时自动化检测页面性能及异常



    优化前


    优化后


    五、优化总结和未来规划



    1. 以上优化手段主要是围绕首次加载页面的耗时和渲染优化,但二次加载还有很大的优化空间 如 PWA 的使用、非直出页面骨架屏处理、CSR 转 SSR等

    2. 对比竞品发现我们 CDN 的下载耗时较长,近期准备启动 CDN 上云,期待上云后 CDN 的效果提升。

    3. 项目迭代一直在进行,需要思考在工程上如何持续保障页面性能

    4. 上文是围绕课程详情页进行的分析和优化处理,虽然对项目整体做了优化处理,但性能优化没有银弹,不同页面的优化要根据页面具体需求进行,需要开发同学主动关注。

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

    收起阅读 »

    code review 流程探索

    前言 没有无缘无故的爱,也没有无缘无故的恨,当然也没有无缘无故的 code review 为什么要 CR 给大家讲个故事,“大神 A”上班时突然恼羞成怒的骂道,这是谁写的代码,没有注释啥也没有,这么明显的 bug。当时整个小组都不敢说话,慌的要死,生怕说的就是...
    继续阅读 »

    前言


    没有无缘无故的爱,也没有无缘无故的恨,当然也没有无缘无故的 code review


    为什么要 CR


    给大家讲个故事,“大神 A”上班时突然恼羞成怒的骂道,这是谁写的代码,没有注释啥也没有,这么明显的 bug。当时整个小组都不敢说话,慌的要死,生怕说的就是自己。领导发话:“大神 A”查下提交记录,谁提交的谁请吃饭。过了两分钟,“大神 A”:这,这是我自己一年前提交的。所以不想自己尴尬,赶紧 code review 吧


    一、角色职能



    author 即需求开发者。要求:



    1. 注重注释。对复杂业务写明相应注释,commit 写名具体提交背景,便于 reviewer 理解。

    2. 端正心态接受他人 review。对 reviewer 给出的 comment,不要有抵触的情绪,对你觉得不合理的建议,可以委婉地进行拒绝,或者详细说明自己的看法以及原因。reviewer 持有的观点并不一定是合理的,所以 review 也是一个相互学习的过程。

    3. 完成 comment 修改后及时反馈。commit 提交信息备注如"reivew: xxxx",保证复检效率。


    reviewer 作为 cr 参与者,建议由项目责任人和项目参与者组成。要求:



    1. 说明 comment 等级。reviewer 对相应代码段提出评价时,需要指明对应等级,如

      • fix: xxxxxxx 此处需强制修改,提供修改建议

      • advise: xxxxxxx 此处主观上建议修改,不强制,可提供修改建议

      • question: xxxxxx 此处存在疑虑,需要 author 作出解释



    2. 友好 comment。评价注意措辞,可以说“我们可以如何去调整修改,可能会更合适。。。”,对于比较好的代码,也应该给与足够的赞美。

    3. 享受 review。避免以挑毛病的心态 review,好的 reviewer 并不是以提的问题多来衡量的。跳出自己的编码风格,主动理解 author 的思路,也是一个很好的学习过程。


    二、CR 流程


    1、self-review



    • commit 之前要求 diff 一下,查看文件变更情况,可接着 gitk 完成。当然如果项目使用 pre-commit 关联 lint 校验,也能发现例如 debugger、console.log 之类语句。但是仍然提倡大家每次提交之前检查一下提交文件。

    • 多人协作下的 commit。多人合作下的分支在合并请求时,需要关注是否带入没必要的 commit。

    • commit message。建议接入 husky、commitlint/cli 以及 commitlint/config-conventional 校验 commit message。commitlint/config-conventional 所提供的类型如

      • feat: 新特性

      • fix: 修改 bug

      • chore: 优化,如项目结构,依赖安装更新等

      • docs: 文档变更

      • style: 样式相关修改

      • refactor:项目重构




    此目的为了进一步增加 commit message 信息量,帮助 reviewer 以及自己更有效的了解 commit 内容。


    2、CR



    1. 提测时发起 cr,需求任务关联 reviewer。提供合并请求,借助 gitlab/sourcetree/vscode gitlens 等工具。reviewer 结束后给与反馈

    2. 针对 reviewer 提出的建议修改之后,commit message 注明类似'review fix'相关信息,便于 reviewer 复检。

    3. 紧急需求,特事特办,跳过 cr 环节,事后 review。


    三、CR 标准



    1. 不纠结编码风格。编码风格交给 eslint/tslint/stylelint

    2. 代码性能。大数据处理、重复渲染等

    3. 代码注释。字段注释、文档注释等

    4. 代码可读性。过多嵌套、低效冗余代码、功能独立、可读性变量方法命名等

    5. 代码可扩展性。功能方法设计是否合理、模块拆分等

    6. 控制 review 时间成本。reviewer 尽量由项目责任人组成,关注代码逻辑,无需逐字逐句理解。


    四、最后


    总的来说,cr 并不是一个找 bug 挑毛病的过程,更不会降低整体开发效率。其目的是为了保证项目的规范性,使得其他开发人员在项目扩展和维护时节省更多的时间和精力。当然 cr 环节需要团队每一个成员去推动,只有每一个人都认可且参与进来,才能发挥 cr 的最大价值。


    f5e284a8e87e4340b5f20e9c88fb2777_tplv-k3u1fbpfcp-zoom-1.gif


    最后安利一波本人开发vscode小插件搭配gitlab进行review。因为涉及内部代码,暂时不能对外开放,这里暂时提供思路,后续开放具体代码。



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

    收起阅读 »

    Swift编译器Crash—Segmentation fault解决方案

    背景抖音上线 Swift 后,编译时偶现Segmentation fault: 11和Illegal instruction: 4的错误,CI/CD 和本地均有出现,且重新编译后均可恢复正常。由于属于编译器层抛出的 Crash,加之提示的错误代码不固定且非必现...
    继续阅读 »

    背景

    抖音上线 Swift 后,编译时偶现Segmentation fault: 11Illegal instruction: 4的错误,CI/CD 和本地均有出现,且重新编译后均可恢复正常。

    由于属于编译器层抛出的 Crash,加之提示的错误代码不固定且非必现,一时较为棘手。网上类似错误较多,但Segmentation fault属于访问了错误内存的通用报错,参考意义较小。和公司内外的团队交流过,也有遇到类似错误,但原因各不相同,难以借鉴。

    虽然 Swift 库二进制化后,相关代码不会参与编译,本地出现的概率大大减少,但在 CI/CD/仓库二进制化任务中依旧使用源码,出现问题需要手动重试,影响效率且繁琐,故深入编译器寻求解决方案。

    Crash 堆栈




    结论

    简而言之,是 Swift 代码中将在 OC 中声明为类属性的NSDictionary变量,当成 Swift 的Dictionary使用。即一个 immutable 变量当作 mutable 变量使用了。编译器在校验SILInstruction时出错,主动调用abort()结束进程或出现EXC_BAD_ACCESS的 Crash。

    准备工作

    编译 Swift

    由于本地重现过错误,故拉取和本地一致的 swift-5.3.2-RELEASE 版本,同时推荐使用 VSCode 进行调试,Ninja 进行构建。

    Ninja 是专注于速度的小型构建系统。


    注意事项

    • 提前预留 50G 磁盘空间
    • 首次编译时长在一小时左右,CPU 基本打满

    下载&编译源码

    brew install cmake ninja
    mkdir swift-source
    cd swift-source
    git clone git@github.com:apple/swift.git
    cd swift/utils
    ./update-checkout --tag swift-5.3.2-RELEASE --clone
    ./build-script

    主要目录




    提取编译参数

    笔者将相关代码抽离抖音工程, 本地复现编译报错问题后,从 Xcode 中提取编译参数:


    VSCode 调试

    选择合适的 LLDB 插件,以 CodeLLDB 为例配置如下的 launch.json。

    其中args内容为获取前一步提取的编译参数,批量将其中每个参数用双引号包裹,再用逗号隔开所得。

    {
        "version""0.2.0",
        "configurations": [
            {
                "type":  "lldb",
                "request""launch",
                "name""Debug",
                "program""${workspaceFolder}/build/Ninja-DebugAssert/swift-macosx-x86_64/bin/swift",
                "args": ["-frontend","-c","-primary-file"/*and other params*/],
                "cwd""${workspaceFolder}",
            }
        ]
    }

    SIL

    LLVM

    在深入 SIL 之前,先简单介绍 LLVM,经典的 LLVM 三段式架构如下图所示,分为前端(Frontend),优化器(Optimizer)和后端(Backend)。当需要支持新语言时只需实现前端部分,需要支持新的架构只需实现后端部分,而前后端的连接枢纽就是 IR(Intermediate Representation),IR 独立于编程语言和机器架构,故 IR 阶段的优化可以做到抽象而通用。



    Frontend

    前端经过词法分析(Lexical Analysis),语法分析(Syntactic Analysis)生成 AST,语义分析(Semantic Analysis),中间代码生成(Intermediate Code Generation)等步骤,生成 IR。

    IR

    格式

    IR 是 LLVM 前后端的桥接语言,其主要有三种格式:

    • 可读的格式,以.ll 结尾
    • Bitcode 格式,以.bc 结尾
    • 运行时在内存中的格式

    这三种格式完全等价。

    SSA

    LLVM IR 和 SIL 都是 SSA(Static Single Assignment)形式,SSA 形式中的所有变量使用前必须声明且只能被赋值一次,如此实现的好处是能够进行更高效,更深入和更具定制化的优化。

    如下图所示,代码改造为 SSA 形式后,变量只能被赋值一次,就能很容易判断出 y1=1 是可被优化移除的赋值语句。


    结构

    基础结构由 Module 组成,每个 Module 大概相当于一个源文件。Module 包含全局变量和 Function 等。Function 对应着函数,包括方法的声实现,参数和返回值等。Function 最重要的部分就是各类 Basic Block。

    Basic Block(BB) 对应着函数的控制流图,是 Instruction 的集合,且一定以 Terminator Instructions 结尾,其代表着 Basic Block 执行结束,进行分支跳转或函数返回。

    Instruction 对应着指令,是程序执行的基本单元。



    Optimizer

    IR 经过优化器进行优化,优化器会调用执行各类 Pass。所谓 Pass,就是遍历一遍 IR,在进行针对性的处理的代码。LLVM 内置了若干 Pass,开发者也可自定义 Pass 实现特定功能,比如插桩统计函数运行耗时等。

    Xcode Optimization Level

    在 Xcode - Build Setting - Apple Clang - Code Generation - Optimization Level 中,可以选定优化级别,-O0 表示无优化,即不调用任何优化 Pass。其他优化级别则调用执行对应的 Pass。



    Backend

    后端将 IR 转成生成相应 CPU 架构的机器码。

    Swiftc

    不同于 OC 使用 clang 作为编译器前端,Swift 自定义了编译器前端 swiftc,如下图所示。



    这里就体现出来 LLVM 三段式的好处了,支持新语言只需实现编译器前端即可。

    对比 clang,Swift 新增了对 SIL(Swift Intermediate Language)的处理过程。SIL 是 Swift 引入的新的高级中间语言,用以实现更高级别的优化。

    Swift 编译流程

    Swift 源码经过词法分析,语法分析和语义分析生成 AST。SILGen 获取 AST 后生成 SIL,此时的 SIL 称为 Raw SIL。在经过分析和优化,生成 Canonical SIL。最后,IRGen 再将 Canonical SIL 转化为 LLVM IR 交给优化器和后端处理。


    SIL 指令

    SIL 假设虚拟寄存器数量无上限,以%+数字命名,如%0,%1 等一直往上递增 以下介绍几个后续会用到的指令:

    • alloc_stack `: 分配栈内存
    • apply : 传参调用函数
    • Load : 从内存中加载指定地址的值
    • function_ref : 创建对 SIL 函数的引用

    SIL 详细的指令解析可参考官方文档。

    Identifier

    LLVM IR 标识符有 2 种基本类型:

    • 全局标识符:包含方法和全局变量等,以@开头
    • 局部标识符:包含寄存器名和类型等,以%开头,其中%+数字代表未命名变量变量

    在 SIL 中,标识符以@开头

    • SIL function 名都以@+字母/数字命名,且通常都经过 mangle
    • SIL value 同样以%+字母/数字命名,表示其引用着 instruction 或 Basic block 的参数
    • @convention(swift)使用 Swift 函数的调用约定(Calling Convention),默认使用
    • @convention(c)@convention(objc_method)分别表示使用 C 和 OC 的调用约定
    • @convention(method)表示 Swift 实例方法的实现
    • @convention(witness_method)表示 Swift protocol 方法的实现

    SIL 结构

    SIL 实现了一整套和 IR 类似的结构,定制化实现了SILModule SILFunction SILBasicBlock SILInstruction


    调试过程

    复现 Crash

    根据前文的准备工作设置好编译参数后,启动编译,复现 Crash,两种 Crash 都有复现,场景如下图所示。abort()EXC_BAD_ACCESS会导致上文出现的Illegal instruction: 4Segmentation fault: 11错误。由于二者的上层堆栈一致,以下以前者为例进行分析。



    堆栈分析

    通过堆栈溯源可看出是在生成SILFunction后,执行postEmitFunction校验SILFunction的合法性时,使用SILVerifier层层遍历并校验 BasicBlock(visitSILBasicBlock)。对 BasicBlock 内部的SILInstruction进行遍历校验(visitSILInstruction)。

    在获取SILInstruction的类型时调用getKind()返回异常,触发 Crash。




    异常 SIL

    • 由于此时SILInstruction异常,比较难定位是在校验哪段指令时异常,故在遍历SILInstruction时打印上一段指令的内容。
    • swift 源代码根目录执行以下命令,增量编译
    cd build/Ninja-DebugAssert/swift-macosx-x86_64
    ninja

    复现后打印内容如下图所示:

    调试小 tips:LLVM 中很多类都实现了 dump()函数用以打印内容,方便调试。





    // function_ref Dictionary.subscript.setter
    %32 = function_ref @$sSDyq_Sgxcis : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> () // user: %33
    %33 = apply %32<AnyHashable, Any>(, , %24) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> ()
    %34 = load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %43, %37


    正常 SIL

    命令行使用swiftc -emit-silgen能生成 Raw SIL,由于该类引用到了 OC 文件,故加上桥接文件的编译参数,完整命令如下:

    swiftc -emit-silgen /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/SwiftCrash.swift -o test.sil  -import-objc-header /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/Swift_MVP-Bridging-Header.h

    截取部分 SIL 如下

    %24 = alloc_stack $Dictionary<AnyHashable, Any> // users: %44, %34, %33, %31
    %25 = metatype $@objc_metatype TestObject.Type  // users: %40, %39, %27, %26
    %34 = load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %42, %36
    %35 = function_ref @$sSD10FoundationE19_bridgeToObjectiveCSo12NSDictionaryCyF : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // user: %37
    %36 = begin_borrow %34 : $Dictionary<AnyHashable, Any> // users: %38, %37
    %37 = apply %35<AnyHashable, Any>(%36) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // users: %41, %40

    SIL 分析

    对正常 SIL 逐条指令分析

    1. 在栈中分配类型为Dictionary<AnyHashable, Any>的内存,将其地址存到寄存器%24,该寄存器的使用者是%44, %34, %33, %31
    2. %25 表示类型TestObject.Type,即TestObject的类型 metaType
    3. 加载%24 寄存器的值到%34 中,同时销毁%24 的值
    4. 创建对函数_bridgeToObjectiveC()-> NSDictionary的引用,存到%35 中
    • 由于函数名被 mangle,先将函数名 demangle,如下图所示,得到函数



    • @convention(method)表明是 Swift 实例方法,有 2 个泛型参数,其中第一个参数τ_0_0实现了 Hashable 协议
    1. 生成一个和%34 相同类型的值,存入%36,%36 结束使用之前,%34 一直存在
    2. 执行%35 中存储的函数,传入参数%36,返回NSDictionary类型,结果存在%37。其作用就是将Dictionary转成了NSDictionary

    曙光初现

    对比异常 SIL,可以看出是在执行桥接方法_bridgeToObjectiveC()时失败,遂查看源码,发现是一个 OC 的NSDictionary不可变类型桥接到 Swift 的Dictionary成为一个可变类型时,对其内容进行修改。虽然这种写法存在可能导致逻辑异常,但并不致编译器 Crash,属于编译器代码 bug。更有意思的是,只有在 OC 中将该属性声明为类属性(class)时,才会导致编译器 Crash。

    class SwiftCrash: NSObject {
      func execute() {
        //compiler crash
        TestObject.cachedData[""] = ""
      }
    }
    @interface TestObject : NSObject
    @property (strong, nonatomic, class) NSDictionary *cachedData;
    @end

    解决方案

    源码修改

    找到错误根源就好处理了,将问题代码中的 NSDictionary 改成 NSMutableDictionary 即可解决。

    重新运行 Swift 编译器编译源码,无报错。

    修改抖音源码后,也再没出现编译器 Crash 的问题,问题修复。

    静态分析

    潜在问题

    虽然NSDictionary正常情况下可以桥接成 Swift 的Dictionary正常使用,但当在 Swift 中对 immutable 对象进行修改后,会重新生成新的对象,对原有对象无影响,测试代码和输出结果如下:

    可以看出变量temp内容无变化,Swift 代码修改无效。

    TestObject *t = [TestObject new];
    t.cachedData = [@{@"oc":@"oc"} mutableCopy];
    NSDictionary *temp = t.cachedData;
    NSLog(@"before execution : temp %p: %@",temp,temp);
    NSLog(@"before execution : cachedData %p: %@",t.cachedData,t.cachedData);
    [[[SwiftDataMgr alloc] init] executeWithT:t];
    NSLog(@"after execution : temp %p: %@",temp,temp);
    NSLog(@"after execution : cachedData %p: %@",t.cachedData,t.cachedData);
    class SwiftDataMgr: NSObject {
      @objc
      func execute(t : TestObject) {
        t.cachedData["swift"] = "swift"
      }
    }




    新增规则

    新增对抖音源码的静态检测规则,检测所有 OC immutable 类是否在 Swift 中被修改。防止编译器 crash 和导致潜在的逻辑错误。

    所有需检测的类如下:

    NSDictionary/NSSet/NSData/NSArray/NSString/NSOrderedSet/NSURLRequest/
    NSIndexSet/NSCharacterSet/NSParagraphStyle/NSAttributedString

    后记

    行文至此,该编译器 Crash 问题已经解决。同时近期在升级 Xcode 至 12.5 版本时又遇到另一种编译器 Crash 且未提示具体报错文件,笔者如法炮制找出错误后并修复。待深入分析生成SILInstruction异常的根本原因后,另起文章总结。


    摘自字节跳动技术团队:https://mp.weixin.qq.com/s?__biz=MzI1MzYzMjE0MQ==&mid=2247489361&idx=1&sn=0b3b071f2103f8082d686d308379dfd1&chksm=e9d0dcb3dea755a579abbb1f5143c6228e72aad4ef3727509d459f88d8650ec71decab65ac00&scene=178&cur_album_id=1590407423234719749#rd






    收起阅读 »

    【开源项目】音视频LowCode平台——AgoraFlow

    此开源项目由热心网友@Lu-Derek 开发AgoraFlow是一款音视频 Low Code Web 共享编辑器。将音视频相关功能进行模块化集成,提供一个图形化界面,让开发者可以用做 PPT 的形式来完成想要实现的功能。Q:AgoraFlow是一个怎么样的项目...
    继续阅读 »

    此开源项目由热心网友@Lu-Derek 开发

    AgoraFlow是一款音视频 Low Code Web 共享编辑器。将音视频相关功能进行模块化集成,提供一个图形化界面,让开发者可以用做 PPT 的形式来完成想要实现的功能。

    Q:AgoraFlow是一个怎么样的项目?

    A:AgoraFlow是一个基于Agora+环信MQTT服务,在Low Code方向上的一次尝试。除了Agora Vue Web SDK和环信之外,还用到了Node-RED,一个IBM的物联网框架。

    这个项目允许用户通过拖拽的方式安排音视频流在不同设备上的采集-发布-订阅-播放行为。在积木式的搭建工作流后,程序会根据逻辑在指定设备部署应用。


    Q:为什么要做这样一个项目?

    A:作为程序员,总会耳濡目染一些行业热点。最近Low Code比较火,所以想看看Low Code和音视频结合会产生什么样的火花。其实大家对于Low Code的产品形态还没有一个准确的定位,所以我想利用这次编程赛阐述我的理解。


    Q:对于这个项目的技术选型有什么想说的?

    我觉得大家都应该利用自己的【工作技能】,接触一些【工作内容】以外的东西。这是我在这个项目中使用了平时并不常用的技术栈、选型比较奇怪的原因。我使用了声网尚在Beta中的产品【Agora
    Vue
    SDK】、使用了偏物联网行业的技术栈【Node-RED】和【MQTT】,按照我的理解做了这样一个不完整但是有趣的产品。对我来说,这是一次尝试,一次娱乐,而非工作。



    【项目介绍】

    AgoraFlow

    基于声网+环信MQTT的音视频LowCode平台

    https://agoraflow.wrtc.dev/


    安装/设置:
    npm install

    访问 http://localhost:1880  即可编辑音视频上下行

    运行:

    目前支持四台设备:
    device1:https://agoraflow.wrtc.dev/devices/index.html?deviceName=device1
    device2:https://agoraflow.wrtc.dev/devices/index.html?deviceName=device2
    device3:https://agoraflow.wrtc.dev/devices/index.html?deviceName=device3
    device4:https://agoraflow.wrtc.dev/devices/index.html?deviceName=device4

    使用四代设备分别打开网页,网页的上下行受low code平台控制


    Github地址:

    https://hub.fastgit.org/AgoraIO-Community/RTE-2021-Innovation-Challenge/tree/master/Application-Challenge/%E3%80%90%E7%BA%A2%E9%B2%A4%E9%B1%BC%E4%B8%8E%E7%BB%BF%E9%B2%A4%E9%B1%BC%E4%B8%8E%E9%A9%B4%E3%80%91AgoraFlow

    作者联系邮箱:scaret.in@gmail.com

    收起阅读 »

    iOS之网络优化

    一、正常一个网络请求过程正常一条网络请求需要经过:DNS解析,请求DNS服务器,获取对应的IP地址与服务端建立连接,TCP三次握手,安全协议的同步流程连接建立完成,发送和接受数据,解码数据。优化点:直接使用IP地址,除去DNS解析的流程不要每个请求都重复建立连...
    继续阅读 »

    一、正常一个网络请求过程

    正常一条网络请求需要经过:

    • DNS解析,请求DNS服务器,获取对应的IP地址
    • 与服务端建立连接,TCP三次握手,安全协议的同步流程
    • 连接建立完成,发送和接受数据,解码数据。

    优化点:

    • 直接使用IP地址,除去DNS解析的流程
    • 不要每个请求都重复建立连接,复用连接使用同一条连接(长连接)
    • 压缩数据,减少传输数据的大小

    二、正常的DNS流程

    DNS完整的解析流程:

    1. 先动本地系统缓存获取,偌没有就到最近的DNS服务器获取。

    2. 偌依旧没有,就到主域名服务器获取,每一层都有有缓存。

    为了域名解析的实时性,每一层的缓存都有一个过期时间。

    缺点:

    1. 缓存时间过长,域名更新不及时,设置的端,单量的DNS解析请求影响速度

    2. 域名劫持,容易被中间人攻击或运营商劫持,把域名解析到第三⽅IP地址,劫持率⽐较⾼。

    3. DNS解析过程不受控制,⽆法保证解析到最快的IP

    4. ⼀次请求只能解析到⼀个域名

    处理DNS耗时和防劫持的方式

    HTTPDNS原理就是:

    ⾃⼰做域名解析⼯作,通过HTTP请求后台去拿到域名对应的IP地址,可以解决上述问题。

    • 域名解析与请求分离,所有的请求都直接⽤IP,⽆需DNS解析,APP定时请求HTTPDNS服务器更 新IP地址即可

    • 通过签名等⽅式,保证HTTPDNS请求的安全性,避免被劫持

    • DNS解析有⾃⼰控制,可以确保更具⽤⼾所在地返回就近的IP地址,或者根据客⼾端测速结果使⽤ 速度最快的IP

    • ⼀次请求可以解析多个域名

    三、TCP连接耗时优化

    解决思路就是:复用接连

    • 不⽤每次重新建⽴连接,⽅案是⾼效的复⽤连接

    HTTP1.1版本产生的问题:

    • HTTP1.1 的问题是默认开启的keep-alive,⼀次的连接只能发送接收⼀个请求,同时发起多个请求,会产⽣问题。

    • 若串⾏发送请求,可以⼀直复⽤⼀个连接,但是速度很慢,每个请求都需要等待上⼀个请求完成再发 送,也产⽣了连接的浪费,没⽤充分的利⽤带宽

    • 若并⾏发送请求,那么⾸次请求都要TCP三次握⼿建⽴新的连接,即使第⼆次请求可以复⽤连接池的 连接,但是会导致连接池的连接过多,对服务端资源产⽣浪费,若限制保持的连接数,会有超出的连 接仍要每次建⽴连接。

    HTTP2.0 提出了多路复⽤的⽅式解决HTTP1.1的问题:

    • HTTP2 的多路复⽤机制也是复⽤连接,但是它的复⽤的这条连接⽀持同时处理多条请求,所有的请求 都可以并发的在这条连接上进⾏,解决了并发请求需要建⽴多次连接的问题

    • HTTP2 把连接⾥传输的数据都封装成⼀个个stream,每个stream都有⼀个标识,stream的发送和接 收可以是乱序,不依赖顺序,不会有阻塞的问题,接收端可以根据stream的标识区分属于哪个请求, 在进⾏数据拼接,最终得到数据

    HTTP2.0 TCP队头阻塞

    • HTTP2还是有问题存在,就是队头阻塞,这是受限于TCP协议,TCP协议为保证数据的可靠性,若传 输过程中有⼀个TCP的包丢失,会等待这个包重传之后,才会处理后续的包.

    • HTTP2的多路复⽤让所 有的请求都在同⼀条连接上,中间有⼀个包丢失,就会阻塞等待重传,所有的请求也会被阻塞

    HTTP2.0 TCP队头阻塞解决方案

    • 这个问题需要改变TCP协议,但是TCP协议依赖操作系统实现以及部分硬件的定制,所以改进缓慢。
    • 于是Google提出了QUIC协议,相当于在UDP的基础上在定义⼀套可靠的传输协议,解决TCP的缺陷, 包括队头阻塞,但是客⼾端少有介⼊

    四、传输数据优化

    传输数据有⼤⼩,数据也会对请求速度有影响。主要优化两个方面:

    • 压缩率,⽽是解压序列化反
      序列化的速度。使⽤Protobuf 可以⽐json的数据量⼩⾄少⼀个数量级

    • 压缩算法的选择,⽬前⽐较好的是Z-Standard HTTP的请求头数据的在HTTP2中也进⾏了压缩。

    五、弱⽹优化

    根据不同的⽹络设置不同的超时时间

    六、数据安全优化

    使⽤Https,是基于http协议上的TLS安全协议,安全协议解决了保证安全降低加密成本

    1、安全上

    • 使⽤加密算法组合对传输的数据加密,避免被窃听和篡改

    • 认证对⽅⾝份,避免被第三⽅冒充

    • 加密算法保持灵活可更新,防⽌定死算法被破解后⽆法更换,禁⽌已被破解的算法

    2、降低加密成本

    • ⽤对称加密算法加密传输的数据,解决⾮对称加密算法的性能低和⻓度限制的问题

    • 缓存安全协议握⼿后的秘钥等数据,加快第⼆次建⽴连接的速度

    • 3、加快握⼿过程2RTT -> 0RTT。加快握⼿的思路,原本客⼾端和服务端需要协商使⽤什么算法后才 可以加密发送数据,变成通过内置的公钥和默认算法,在握⼿的同时,就把数据发送出去,不需要等 待握⼿就开始发送数据,达到0RTT

    3.充分利用缓存

    • Get请求可以被缓存,Get请求也是幂等的,
    • 简单的处理缓存的80%需求

    使⽤Get请求的代码设置如下:


    ///objective-c代码 
    NSURLCache *urlCache =[[NSURLCache alloc]initWithMemoryCapacity:4 *1024 * 1024
    diskCapacity:20 * 1024 *1024 diskPath:nil];
    [NSURLCachesetSharedURLCache:urlCache];

    4、控制缓存的有效性

    1、⽂件缓存:借助ETag或者Last-Modified判断⽂件缓存是不是有效

    Last-Modified

    • ⼤多采⽤资源变动后就重新⽣成⼀个链接的做法,但是不排除没有换链接的,这种情况下就需要借助 ETagorLast-Modified判断⽂件的有效性

    • Last-Modified 资源的最后修改时间戳,与缓存时间进⾏对⽐来判断是否过期,请求时返回If- Modified-Since,返回的数据若果没变化http返回的状态码就是403(Not changed),内容为空,节省 传输数据

    ETagIf-None-Match

    • HTTP 协议规格说明定义ETag为“被请求变量的实体值” 。另⼀种说法是,ETag是⼀个可以与Web 资源关联的记号(token)。
    • 它是⼀个 hash 值,⽤作 Request 缓存请求头,每⼀个资源⽂件都对应⼀ 个唯⼀的 ETag 值。如果Etag没有改变,则返回状态码304,返回的内容为空

    下载的图⽚的格式最好是WebP格式,因为他是同等图⽚质量下最⼩数量的图⽚,可以降低流量损失



    作者:枫叶无处漂泊
    链接:https://www.jianshu.com/p/66ee6798b99a

    收起阅读 »

    iOS 渲染过程

    iOS
    背景 app如何快速显示首屏? 滑动列表时候如何做到流畅? 当我们说界面卡了我们在说什么? ...... 应用运行的卡顿率是一个十分重要的指标,相比慢、发热、占用内存高来讲,卡顿是用户第一时间能感知的东西,三步两卡的应用基本逃不出被卸载的命运,要想优化卡顿...
    继续阅读 »

    背景



    app如何快速显示首屏?

    滑动列表时候如何做到流畅?

    当我们说界面卡了我们在说什么?

    ......



    应用运行的卡顿率是一个十分重要的指标,相比慢、发热、占用内存高来讲,卡顿是用户第一时间能感知的东西,三步两卡的应用基本逃不出被卸载的命运,要想优化卡顿就要搞清楚画面卡住不动的原因,这就需要对整个渲染过程有一定了解,本文会从图层说起,来聊聊整个渲染过程以及优化点,在写这篇文章之前笔者努力在想,对于完全没有做过图形处理相关工作的工程师来说,理解这个过程是有一定难度的,那么要怎么写才可以脉络清晰又浅显易懂呢,想来想去还是从日常开发中的界面UI开始分析吧,毕竟可直接感知


    从一个简单的界面开始

    - (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = UIColor.blueColor;
    // 蒙板视图
    UIView *maskView = [[UIView alloc] initWithFrame:self.view.bounds];
    maskView.backgroundColor = [UIColor redColor];
    maskView.alpha = 0.3;
    [self.view addSubview:maskView];
    // 初始化屏幕大小的矩形路径
    UIBezierPath *bpath = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, [[UIScreen mainScreen] bounds].size.width, [[UIScreen mainScreen] bounds].size.height) cornerRadius:8.0];
    // 中间添加一个圆形的路径
    [bpath appendPath:[UIBezierPath bezierPathWithArcCenter:maskView.center radius:100 startAngle:0 endAngle:2*M_PI clockwise:NO]];
    //创建一个layer设置其CGPath为我们创建的路径并且赋值给蒙板视图的layer
    CAShapeLayer *shapeLayer = [CAShapeLayer layer];
    shapeLayer.path = bpath.CGPath;
    maskView.layer.mask = shapeLayer;
    }

    效果如下:我们设置self.view的背景色为蓝色,然后添加了一层中间镂空的红色透明度0.3的蒙板,经过叠加、裁剪、混合(后面会详述)

    数一数得到图中的效果,我们一共用了几个元素



    • 一个蓝色背景UIView

    • 一个蒙板视图UIView

    • 一个用贝塞尔曲线修改过的CAShapeLayer


    我们自己创建的元素有三个,背景视图上面添加蒙板视图,蒙板视图的图层的蒙板层mask设置为自定义的镂空CAShapeLayer层,可见开发中既有视图又有图层,会不会觉得有些冗余呢


    视图和图层为何拆分开来又聚合在一起呢


    这是自图形界面发明出来就广泛应用的设计,拆开之后,layer负责界面显示,view负责事件分发,聚合一起是因为操作起来更方便,不能说开发者设置完事件层次之后,再设置一遍图层层次吧



    iOS中用UIView和CALayer来描述两者,所有视图相关的类都继承自UIView,所有图层相关的类都继承自CALayer,view是layer的代理并且苹果建议我们不要修改这种关系,为了对开发者更友好,view中暴露了一些界面设置相关的属性,所有view显示相关的设置最终都会映射到与之绑定的layer上,这样的api设计有意隐藏了CALayer的部分功能,对开发者来讲不用关心那么多显示相关的细节



    因此当我们研究渲染过程的时候,只需要关注CALayer即可


    渲染数据流


    纵观我们的app界面,图层上面加图层,图层又有子图层,整个结构是以CALayer或其子类对象为节点连接而成的树型结构,我们通常称之为图层树,苹果称其为模型树,layer默认是个矩形,我们可以通过path属性修改其形状,可以修改为圆形、三角形等任何规则不规则的形状,那么从图层树到屏幕上显示的界面都需要哪些步骤呢,如果你去查资料你可能会翻到图层树->呈现树->渲染树,那么他们都是什么东西呢,数据是如何流转的,CPU、GPU和渲染引擎都扮演了怎样的角色呢,我们来拆解下渲染数据流的处理过程



    用CALayer构建图层树

    我们编写的所有UI代码,最终都会以一个个的CALayer对象的形式被添加到渲染数据流中,在此过程中会做如下处理




    • 视图懒加载

      这是常规设计,通常所有界面元素都会用懒加载的方式去处理,即只有视图需要显示的时候才去加载它,以最大化优化内存,此过程同时会进行图片解压缩(当设置资源路径到UIImage或者UIImageVIew的时候)


    • 布局计算

      加载完视图之后,会进行addSubview、addSublayer操作,这是在设置图层之间的关系,所有图层会以superlayer、sublayer指针连接起来成为一个树型结构,一个节点只能有一个superlayer,可以有无限个sublayer,每个节点承载着与父子节点的位置关系以及渲染属性,当发生布局改变的时候,若是单纯的修改某个节点的渲染属性,则开销较小,若是修改层级,则整个图层树都需要重新计算修正


    • Core Graphics绘制

      如果实现了-drawRect:或者-drawLayer:inContext:方法,系统会以当前layer为画布创建一个寄宿图来单独绘制字符串或者图片,若是图片,同样会进行图片解压缩操作



    以上过程图层树已生成,就是以CALayer对象为节点的树型结构



    Core Animation构建呈现树

    图层树仅仅是一个数据结构,而GPU只负责计算处理图形图像数据,因此在发送数据到渲染服务之前还需要把图层树图形图像化,在此过程中会做如下处理




    • CALayer生成图形

      遍历图层树,取出每个layer节点,根据渲染属性生成图形,通常都是矩形(可以是任意形状就像文章开篇那个界面一样)


    • 图片生成位图

      无论是直接通过UIImage或者UIImageView加载的图片还是通过drawRect或者drawLayer:inContext绘制的图片都会在此过程中生成位图(第一步仅仅是解码生成非压缩二进制流)



    以上过程呈现树已生成,图层树、呈现树构建过程都是由CPU负责计算



    渲染服务

    呈现树已经是图形图像组织而成的树型结构,Core Animation通过IPC进程通信将其发送到渲染服务进程




    • 生成纹理

      如上的图形图像都是非格式化的数据,在计算机图形学里面通常称为Buffer,而GPU能处理的是格式化纹理数据Texture,在此过程中Core Animation会将呈现树Buffer数据通过渲染引擎转化为纹理数据Texture,自此渲染树已生成,这一步骤仍然由CPU计算


    • 顶点数据:包括顶点坐标、纹理坐标、顶点法线和顶点颜色等属性,顶点数据构成的图元信息(点、线、三角形等)需要参数代入绘制指令


    • 顶点着色器:将输入的局部坐标变换到世界坐标、观察坐标和裁剪坐标


    • 图元装配:将输入的顶点组装成指定的图元,这个阶段会进行裁剪和背面剔除相关优化


    • 几何着色器:将输入的图元扩展成多边形,将物体坐标变换为窗口坐标


    • 光栅化:将多边形转化为离散屏幕像素点并得到片元信息


    • 片元着色器:通过片元信息为像素点着色,这个阶段会进行光照计算、阴影处理等特效处理


    • 测试混合阶段:依次进行裁切测试、Alpha测试、模板测试和深度测试


    • 帧缓存:最终生成的图像存储在帧缓存,然后放入渲染缓冲区

    • 显示到屏幕



    以上过程都由渲染引擎处理,除了生成纹理是CPU负责,其他都全权由GPU负责,以上就是界面渲染的全部过程,那我们自定义的画布呢,比如常见的播放器业务、地图业务都是怎么最终显示到屏幕的呢


    自定义画布


    我这篇文章有详述其组织渲染过程:https://www.jianshu.com/p/4613e0bcd31f,但是渲染到帧缓存之后,显示到屏幕过程是怎样的呢

    iOS中是不支持直接渲染到屏幕的,我们的自定义画布,需要配合Core Animation来完成最终的显示,诸如播放器、地图等业务得到的渲染缓冲renderBuffer,需要通过layer关联到Core Animation层,待到生成渲染树的时候替换原来的内容,此过程只是一个指针替换,即将渲染树对应层的指针指向renderBuffer,然后由渲染引擎通过GPU最终将其呈现到屏幕


    图层截图黑屏问题


    播放器、地图类业务图层截图的时候会黑屏,原因是图层截图截取的是呈现树,此时自定义画布得到的renderBuffer还没有替换layer原来的内容,解决这个问题需要在截图的时候,将renderBuffer的内容在layer的上下文上单独绘制


    当我们说界面卡了我们在说什么



    屏幕会以60帧每秒的频率刷新,就是16.7毫秒一帧,系统渲染进程是不会卡的,除非发生了系统错误或者硬件错误,通常渲染服务进程会一直以16.7毫秒一帧不停的刷新,这个过程由VSync信号驱动,VSync信号由硬件时钟生成,每秒钟发出60次,渲染服务进程接收到VSync信号后,会通过IPC通知到活跃的App进程,进程内的所有线程的Runloop在启动后会注册对应的CFRunLoopSource,通过mach_port接收传过来的VSync信号,Runloop随之执行一次以驱动整个app的运行



    页面卡顿,现象是当我们滑动列表或者点击一个按钮之后页面没有响应,本质原因是主线程卡了,因为整个UI界面的构建、计算、合成纹理过程都是在主线程进行的,主线程16.7毫秒之内没有执行完当前任务,该任务可能是:



    • 非UI业务逻辑耗时过多

    • 图层树构建组织过程耗时过多

    • 呈现树生成过程耗时过多


    总之是渲染树没有更新导致看上去还是上一帧的画面,这就是卡顿


    卡顿优化


    优化卡顿无非就是减少上面几个步骤的耗时,使Runloop能在16.7毫秒之内执行完一次





    • 非UI业务逻辑耗时

      尽可能的将其放在非主线程执行,完成之后异步刷新UI






    • 图层树构建组织过程耗时

      1、布局计算的耗时与图层的个数、图层之间的层级关系和位置关系呈线性关系,即图层越多越耗时,图层关系越复杂越耗时,因此尽量用更少的图层个数和简单的图层关系来布局就是优化方向,而且尽量不要动态修改图层的层级关系,否则整个图层树都需要重新计算修正

      2、尽量不要重写-drawRect:或者-drawLayer:inContext:方法,因为Core Animation不得不生成一张layer等大小的寄宿图用于绘制,不仅占用额外的内存而且绘制过程是CPU计算的

      3、图片解码尽量在需要展示之前进行,SDWebImage做的就很好,不仅优化了图片文件IO而且图片解码也是在非主线程执行,完成之后异步刷新UI






    • 呈现树生成过程耗时

      这里要纠正个问题,离屏渲染是常规操作,经过优化的播放器、地图等业务都是用的离屏渲染,发生在主线程的离屏渲染才有性能问题,可以用CPU渲染也可以用GPU渲染,离屏渲染也是同理

      1、CALayer生成图形,离屏渲染发生在这个阶段,对于特定图层的圆角、图层遮罩、阴影或者是图层光栅化都会使Core Animation不得不进行当前图层的离屏绘制,不过在界面设计的时候,大多数设计师都钟爱于以上效果,使用起来也没有太大影响,只是会造成多余的计算、耗时和内存占用,如果不是列表型的界面,可以尽情的使用,对于列表型界面,我们可以禁用shouldRasterize(就是光栅化),这将会让图层离屏渲染一次后把结果保存起来,后面刷新会直接用缓存的结果

      2、图片生成位图,显然图片越多、越大计算量越大,就越耗时,因此如果能用CALayer实现的效果,尽量不要让设计师出图,不仅占用存储空间、占用内存而且加载耗时



    总结


    本文从一个简单的界面开始详细阐述了iOS渲染过程以及卡顿优化点,有些内容官方文档写的很清楚,甚至还有demo,大家在学习的时候首先应该关注的就是官方文档,下面给出两个官方参考链接:


    https://developer.apple.com/documentation/quartzcore/calayer?language=objc


    https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/WorkingwithEAGLContexts/WorkingwithEAGLContexts.html#//apple_ref/doc/uid/TP40008793-CH103-SW7



    链接:https://www.jianshu.com/p/0ced61fd1f21

    收起阅读 »

    Flutter框架分析-BasicMessageChannel

    1. 前言 在文章Flutter框架分析(八)-Platform Channel中,我们分析了BasicMessageChannel的原理和结构,并详细讲解了与其相关的一些核心类,例如MessageHandler和MessageCodec等,本文主...
    继续阅读 »

    1. 前言


    在文章Flutter框架分析(八)-Platform Channel中,我们分析了BasicMessageChannel的原理和结构,并详细讲解了与其相关的一些核心类,例如MessageHandlerMessageCodec等,本文主要讲解使用BasicMessageChannel的示例。


    2. 使用流程


    BasicMessageChannel可用于Flutternative发消息,也可用于nativeFlutter发消息,所以接下来将分别分析这两种使用流程。


    2.1  Flutter给native发消息


    流程如下:


    1)native端创建某channel name的BasicMessageChannel


    2)native端使用setMessageHandler函数,设置该BasicMessageChannelMessageHandler


    3)Flutter端创建该channel name的BasicMessageChannel


    4)Flutter端使用该BasicMessageChannel通过send函数向native端发送消息。


    5)native端刚刚注册的MessageHandler收到发送的消息,在onMessage中处理消息,通过reply函数进行回复。


    6)Flutter端处理该回复。


    Flutter端关键代码如下:


    class _MessageChannelState extends State<MessageChannelWidget> {
      static const  _channel = BasicMessageChannel('flutter2/testmessagechannel', JSONMessageCodec());
      int i = 0;

      void _sendMessage() async {
        final String reply = await  _channel.send('Hello World i: $i');
        print('MessageChannelTest in dart $reply');
        setState(() {
          i++;
        });
      }


      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("message channel test"),
          ),
          body: Center(
             // Center is a layout widget. It takes a single child and positions it
    // in the middle of the parent.
    child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Text(
                  'You have pushed the button this many times:',
                ),
                Text(
                  '$i',
                  style: Theme.*of*(context).textTheme.headline4,
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed:_sendMessage,
            tooltip: 'Increment',
            child: Icon(Icons.add),
          ),  // This trailing comma makes auto-formatting nicer for build methods);
      }
    }

    native端关键代码如下:


    class MessageChannelActivity: FlutterActivity() {

        companion object {
            fun startActivity(activity: Activity) {
                val intent = Intent(activity, MessageChannelActivity::class.java)
                activity.startActivity(intent)
            }
        }

        override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {

            Log.d("MessageChannelTest""MessageChannelActivity configureFlutterEngine")
    val channel = BasicMessageChannel(
                    flutterEngine.dartExecutor.binaryMessenger,
                    "flutter2/testmessagechannel",
                JSONMessageCodec.INSTANCE)

             // Receive messages from Dart
    channel.setMessageHandler  {message, reply ->
    Log.d("MessageChannelTest""in android Received message = $message")
                reply.reply("Reply from Android")
            }
        }
    }

    2.2  native给Flutter发消息


    流程如下:


    1) Flutter端创建该channel name的BasicMessageChannel


    2) Flutter端使用setMessageHandler函数,设置该BasicMessageChannelHandler函数。


    3) native端创建某channel name的BasicMessageChannel


    4) native端使用该BasicMessageChannel通过send函数向Flutter端发送消息。


    5) Flutter端刚刚注册的Handler收到发送的消息,并处理消息,然后通过reply函数进行回复。


    6) native端处理该回复。


    Flutter端关键代码如下:


    class _MessageChannelState extends State<MessageChannelWidget> {
      static const  _channel = BasicMessageChannel('flutter2/testmessagechannel', JSONMessageCodec());
      int i = 0;

      void _sendMessage() async {
        final String reply = await  _channel.send('Hello World i: $i');
        print('MessageChannelTest in dart $reply');
        setState(() {
          i++;
        });
      }

      @override
      void initState() {
         // Receive messages from platform
    _channel.setMessageHandler((dynamic message) async {
          print('MessageChannelTest in dart Received message = $message');
          return 'Reply from Dart';
        });
        super.initState();
      }
    }

    native端关键代码如下:


    class MessageChannelActivity: FlutterActivity() {

        companion object {
            fun startActivity(activity: Activity) {
                val intent = Intent(activity, MessageChannelActivity::class.java)
                activity.startActivity(intent)
            }
        }

        override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {

            Log.d("MessageChannelTest""MessageChannelActivity configureFlutterEngine")

    val channel = BasicMessageChannel(
                    flutterEngine.dartExecutor.binaryMessenger,
                    "flutter2/testmessagechannel",
                JSONMessageCodec.INSTANCE)

    // Send message to Dart
    Handler().postDelayed( {
    channel.send("Hello World from Android")  { reply ->
    Log.d("MessageChannelTest""in android $reply")
                 }
    }, 500)
        }
    }

    3. 小结


    本文主要介绍了BasicMessageChannel的使用流程,并列举了一个使用BasicMessageChannel的示例。


    收起阅读 »

    iOS 离屏渲染的研究

    iOS
    GPU渲染机制: CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。 GPU屏幕渲染有以下两种方式: On-Screen Ren...
    继续阅读 »

    GPU渲染机制:


    CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。




    GPU屏幕渲染有以下两种方式:



    • On-Screen Rendering

      意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行。


    • Off-Screen Rendering

      意为离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作。




    特殊的离屏渲染:

    如果将不在GPU的当前屏幕缓冲区中进行的渲染都称为离屏渲染,那么就还有另一种特殊的“离屏渲染”方式: CPU渲染。

    如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内 同步地

    完成,渲染得到的bitmap最后再交由GPU用于显示。

    备注:CoreGraphic通常是线程安全的,所以可以进行异步绘制,显示的时候再放回主线程,一个简单的异步绘制过程大致如下

     - (void)display {
    dispatch_async(backgroundQueue, ^{
    CGContextRef ctx = CGBitmapContextCreate(...);
    // draw in context...
    CGImageRef img = CGBitmapContextCreateImage(ctx);
    CFRelease(ctx);
    dispatch_async(mainQueue, ^{
    layer.contents = img;
    });
    });
    }



    离屏渲染的触发方式


    设置了以下属性时,都会触发离屏绘制:



    • shouldRasterize(光栅化)

    • masks(遮罩)

    • shadows(阴影)

    • edge antialiasing(抗锯齿)

    • group opacity(不透明)

    • 复杂形状设置圆角等

    • 渐变



    其中shouldRasterize(光栅化)是比较特别的一种:

    光栅化概念:将图转化为一个个栅格组成的图象。

    光栅化特点:每个元素对应帧缓冲区中的一像素。




    shouldRasterize = YES在其他属性触发离屏渲染的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。shouldRasterize = YES,这将隐式的创建一个位图,各种阴影遮罩等效果也会保存到位图中并缓存起来,从而减少渲染的频度(不是矢量图)。




    相当于光栅化是把GPU的操作转到CPU上了,生成位图缓存,直接读取复用。




    当你使用光栅化时,你可以开启“Color Hits Green and Misses Red”来检查该场景下光栅化操作是否是一个好的选择。绿色表示缓存被复用,红色表示缓存在被重复创建。




    如果光栅化的层变红得太频繁那么光栅化对优化可能没有多少用处。位图缓存从内存中删除又重新创建得太过频繁,红色表明缓存重建得太迟。可以针对性的选择某个较小而较深的层结构进行光栅化,来尝试减少渲染时间。




    注意:

    对于经常变动的内容,这个时候不要开启,否则会造成性能的浪费




    例如我们日程经常打交道的TableViewCell,因为TableViewCell的重绘是很频繁的(因为Cell的复用),如果Cell的内容不断变化,则Cell需要不断重绘,如果此时设置了cell.layer可光栅化。则会造成大量的离屏渲染,降低图形性能。




    光栅化有什么好处?



    shouldRasterize = YES在其他属性触发离屏渲染的同时,会将光栅化后的内容缓存起来,如果对应的layer及其sublayers没有发生改变,在下一帧的时候可以直接复用。shouldRasterize = YES,这将隐式的创建一个位图,各种阴影遮罩等效果也会保存到位图中并缓存起来,从而减少渲染的频度(不是矢量图)。



    举个栗子



    如果在滚动tableView时,每次都执行圆角设置,肯定会阻塞UI,设置这个将会使滑动更加流畅。

    当shouldRasterize设成true时,layer被渲染成一个bitmap,并缓存起来,等下次使用时不会再重新去渲染了。实现圆角本身就是在做颜色混合(blending),如果每次页面出来时都blending,消耗太大,这时shouldRasterize = yes,下次就只是简单的从渲染引擎的cache里读取那张bitmap,节约系统资源。



    而光栅化会导致离屏渲染,影响图像性能,那么光栅化是否有助于优化性能,就取决于光栅化创建的位图缓存是否被有效复用,而减少渲染的频度。可以使用Instruments进行检测:



    当你使用光栅化时,你可以开启“Color Hits Green and Misses Red”来检查该场景下光栅化操作是否是一个好的选择。

    如果光栅化的图层是绿色,就表示这些缓存被复用;如果是红色就表示缓存会被重复创建,这就表示该处存在性能问题了。



    注意:

    对于经常变动的内容,这个时候不要开启,否则会造成性能的浪费



    为什么会使用离屏渲染


    当使用圆角,阴影,遮罩的时候,图层属性的混合体被指定为在未预合成之前不能直接在屏幕中绘制,所以就需要屏幕外渲染被唤起。


    屏幕外渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。


    所以当使用离屏渲染的时候会很容易造成性能消耗,因为在OPENGL里离屏渲染会单独在内存中创建一个屏幕外缓冲区并进行渲染,而屏幕外缓冲区跟当前屏幕缓冲区上下文切换是很耗性能的。




    Instruments监测离屏渲染


    Instruments的Core Animation工具中有几个和离屏渲染相关的检查选项:



    • Color Offscreen-Rendered Yellow

      开启后会把那些需要离屏渲染的图层高亮成黄色,这就意味着黄色图层可能存在性能问题。


    • Color Hits Green and Misses Red

      如果shouldRasterize被设置成YES,对应的渲染结果会被缓存,如果图层是绿色,就表示这些缓存被复用;如果是红色就表示缓存会被重复创建,这就表示该处存在性能问题了。





    iOS版本上的优化


    iOS 9.0 之前UIimageView跟UIButton设置圆角都会触发离屏渲染


    iOS 9.0 之后UIButton设置圆角会触发离屏渲染,而UIImageView里png图片设置圆角不会触发离屏渲染了,如果设置其他阴影效果之类的还是会触发离屏渲染的。


    这可能是苹果也意识到离屏渲染会产生性能问题,所以能不产生离屏渲染的地方苹果也就不用离屏渲染了。






    链接:https://www.jianshu.com/p/6d24a4c29e18

    收起阅读 »

    Android 图片转场和轮播特效,你想要的都在这了

    使用 OpenGL 做图像的转场效果或者图片轮播器,可以实现很多令人惊艳的效果。 GLTransitions 熟悉的 OpenGL 开发的朋友已经非常了解 GLTransitions 项目,该项目主要用来收集各种 GL 转场特效及其 GLSL 实现...
    继续阅读 »

    使用 OpenGL 做图像的转场效果或者图片轮播器,可以实现很多令人惊艳的效果。


    ogl.gif


    GLTransitions


    gallery.gif


    熟悉的 OpenGL 开发的朋友已经非常了解 GLTransitions 项目,该项目主要用来收集各种 GL 转场特效及其 GLSL 实现代码,开发者可以很方便地移植到自己的项目中。


    GLTransitions 项目网站地址: gl-transitions.com/gallery


    config.gif


    GLTransitions 项目已经有接近 100 种转场特效,能够非常方便地运用在视频处理中,**很多转场特效包含了混合、边缘检测、腐蚀膨胀等常见的图像处理方法,由易到难。 **


    对于想学习 GLSL 的同学,既能快速上手,又能学习到一些高阶图像处理方法 GLSL 实现,强烈推荐。


    edit.png


    另外 GLTransitions 也支持 GLSL 脚本在线编辑、实时运行,非常方便学习和实践。


    Android OpenGL 怎样移植转场特效


    github.gif github2.gif github3.gif


    由于 GLSL 脚本基本上是通用的,所以 GLTransitions 特效可以很方便地移植到各个平台,本文以 GLTransitions 的 HelloWorld 项目来介绍下特效移植需要注意的几个点。


    GLTransitions 的 HelloWorld 项目是一个混合渐变的特效:


    // transition of a simple fade.
    vec4 transition (vec2 uv) {
    return mix(
    getFromColor(uv),
    getToColor(uv),
    progress
    );
    }


    transition 是转场函数,功能类似于纹理采样函数,根据纹理坐标 uv 输出 rgba ,getFromColor(uv) 表示对源纹理进行采样,getToColor(uv) 表示对目标纹理进行采样,输出 rgba ,progress 是一个 0.0~1.0 数值之间的渐变量,mix 是 glsl 内置混合函数,根据第三个参数混合 2 个颜色。


    根据以上信息,我们在 shader 中只需要准备 2 个纹理,一个取值在 0.0~1.0 的(uniform)渐变量,对应的 shader 脚本可以写成:


    #version 300 es
    precision mediump float;
    in vec2 v_texCoord;
    layout(location = 0) out vec4 outColor;
    uniform sampler2D u_texture0;
    uniform sampler2D u_texture1;
    uniform float u_offset;//一个取值在 0.0~1.0 的(uniform)渐变量

    vec4 transition(vec2 uv) {
    return mix(
    texture(u_texture0, uv);,
    texture(u_texture1, uv);,
    u_offset
    );
    }

    void main()
    {
    outColor = transition(v_texCoord);
    }


    代码中设置纹理和变量:


    glUseProgram (m_ProgramObj);

    glBindVertexArray(m_VaoId);

    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, m_TextureIds[0]);
    GLUtils::setInt(m_ProgramObj, "u_texture0", 0);

    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, m_TextureIds[1]);
    GLUtils::setInt(m_ProgramObj, "u_texture1", 1);

    GLUtils::setFloat(m_ProgramObj, "u_offset", offset);

    glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, (const void *)0);

    本文的 demo 实现的是一个图像轮播翻页效果,Android 实现代码见项目:


    github.com/githubhaoha…


    转场特效移植是不是很简单,动手试试吧。

    收起阅读 »

    深入浅出 NavigationUI | MAD Skills

    这是第二个关于导航 (Navigation) 的 MAD Skills 系列,如果您想回顾过去发布的内容,请参考下面链接查看: 导航组件概览 导航到对话框 在应用中导航时使用 SafeArgs 使用深层链接导航 打造您的首个 app b...
    继续阅读 »

    这是第二个关于导航 (Navigation) 的 MAD Skills 系列,如果您想回顾过去发布的内容,请参考下面链接查看:



    今天为大家发布本系列文章中的第一篇。在本文中,我们将为大家讲解另外一个用例,即类似操作栏 (Action Bar)、底部标签栏或者抽屉型导航栏之类的 UI 组件如何在应用中实现导航功能。如果您更倾向于观看视频而非阅读文章,请查看 视频 内容。


    概述


    在之前的 导航系列文章中,Chet 开发了一个用于 跟踪甜甜圈的应用。知道什么是甜甜圈的最佳搭档吗?(难道是另一个甜甜圈?) 当然是咖啡!所以我准备增加一个追踪咖啡的功能。我需要在应用中增加一些页面,所以有必要使用抽屉式导航栏或者底部标签栏来辅助用户导航。但是我们该如何使用这些 UI 组件来集成导航功能呢?通过点击监听器手动触发导航动作吗?


    不需要!无需任何监听器。NavigationUI 类通过匹配目标页面 id 与菜单 id 实现不同页面之间的导航功能。让我们深入探索一下它的内部机制吧。


    添加咖啡追踪器


    △ 工程结构


    △ 工程结构


    首先我将与甜甜圈相关的类文件拷贝了一份到新的包下,并且将它们重命名。这样的操作对于真正的应用来说也许不是最好的做法,但是在这里可以快速帮助我们添加咖啡跟踪功能到已有的应用中。如果您希望随着文章内容同步操作,可以获取 这里的代码,里面包含了全部针对 Donut Tracker 应用的修改,可以基于该代码了解 NavigationUI。


    基于上面所做的修改,我更新了导航图,新增了从 coffeeFragment 到 coffeeDialogFragment 以及从 selectionFragment 到 donutFragment 相关的目的页面和操作。之后我会用到这些目的页面的 id ;)


    △ 带有新的目的页面的导航图


    △ 带有新的目的页面的导航图


    更新导航图之后,我们可以开始将元素绑定起来,并且实现导航到 SelectionFragment。


    选项菜单


    应用的选项菜单现在尚未发挥作用。要启用它,需要在 onOptionsItemSelected() 函数中,为被选择的菜单项调用 onNavDestinationSelected() 函数,并传入 navController。只要目的页面的 idMenuItem 的 id 相匹配,该函数会导航到绑定在 MenuItem 上的目的页面。


    override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return item.onNavDestinationSelected(
    findNavController(R.id.nav_host_fragment)
    ) || super.onOptionsItemSelected(item)
    }

    现在导航控制器可以 "支配" 菜单项了,我将 MenuItemid 与之前所创建的目的页面的 id 进行了匹配。这样,导航组件就可以将 MenuItem 与目的页面进行关联。


    <menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="com.android.samples.donuttracker.MainActivity">

    <item
    android:id="@+id/selectionFragment"
    android:orderInCategory="100"
    android:title="@string/action_settings"
    app:showAsAction="never" />

    </menu>

    Toolbar


    现在应用可以导航到 selectionFragment,但是标题仍然保持原样。当处于 selectionFragment 的时候,我们希望标题可以被更新并且显示返回按钮。


    首先我需要添加一个 AppBarConfiguration 对象,NavigationUI 会使用该对象来管理应用左上角的导航按钮的行为。


    appBarConfiguration = AppBarConfiguration(navController.graph)

    该按钮会根据您的目的页面的层级改变自身的行为。比如,当您在最顶层的目的页面时,就不会显示回退按钮,因为没有更高层级的页面。



    默认情况下,您应用的最初页面是唯一的最顶层目的页面,但是您也可以定义多个最顶层目的页面。比如,在我们的应用中,我可以将 donutList coffeeList 的目的页面都定义为最顶层的目的页面。



    接下来,在 MainActivity 类中,获得 navControllertoolbar 的实例,并且验证 setSupportActionBar() 是否被调用。这里我还更新了传入函数的 toolbar 的引用。


    val navHostFragment = supportFragmentManager.findFragmentById(
    R.id.nav_host_fragment
    ) as NavHostFragment
    navController = navHostFragment.navController
    val toolbar = binding.toolbar

    要在默认的操作栏 (Action Bar) 中添加导航功能,我在这里使用了 setupActionBarWithNavController() 函数。该函数需要两个参数: navControllerappBarConfiguration


    setSupportActionBar(toolbar)
    setupActionBarWithNavController(navController, appBarConfiguration)

    接下来,根据目前的目的页面,我覆写了 onSupportNavigationUp() 函数,然后在 nav_host_fragment 上调用 navigateUp() 并传入 appBarConfiguration 来支持回退导航或者显示菜单图标的功能。


    override fun onSupportNavigateUp(): Boolean {
    return findNavController(R.id.nav_host_fragment).navigateUp(
    appBarConfiguration
    )
    }

    现在我可以导航到 selectionFragment,并且您可以看到标题已经更新,并且也显示了返回按钮,用户可以返回到之前的页面。


    △ 标题更新了并且也显示了返回按钮


    △ 标题更新了并且也显示了返回按钮


    底部标签栏


    目前为止还算顺利,但是应用还不能导航到 coffeeList Fragment。接下来我们将解决这个问题。


    我们从添加底部标签栏入手。首先添加 bottom_nav_menu.xml 文件并且声明两个菜单元素。NavigationUI 依赖 MenuItemid,用它与导航图中目的页面的 id 进行匹配。我还为每个目的页面设置了图标和标题。


    <menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
    android:id="@id/donutList"
    android:icon="@drawable/donut_with_sprinkles"
    android:title="@string/donut_name" />

    <item
    android:id="@id/coffeeList"
    android:icon="@drawable/coffee_cup"
    android:title="@string/coffee_name" />

    </menu>

    现在 MenuItem 已经就绪,我在 mainActivity 的布局中添加了 BottomNavigationView,并且将 bottom_nav_menu 设置为 BottomNavigationViewmenu 属性。


    <com.google.android.material.bottomnavigation.BottomNavigationView
    android:id="@+id/bottom_nav_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:menu="@menu/bottom_nav_menu" />


    要使底部标签栏发挥作用,这里调用 setupWithNavController() 函数将 navController 传入 BottomNavigationView


    private fun setupBottomNavMenu(navController: NavController) {
    val bottomNav = findViewById<BottomNavigationView>(
    R.id.bottom_nav_view
    )
    bottomNav?.setupWithNavController(navController)
    }

    请注意我并没有从导航图中调用任何导航操作。实际上导航图中甚至没有前往 coffeeList Fragment 的路径。和之前对 ActionBar 所做的操作一样,BottomNavigationView 通过匹配 MenuItemid 和导航目的页面的 id 来自动响应导航操作。


    抽屉式导航栏


    虽然看上去不错,但是如果您设备的屏幕尺寸较大,那么底部标签栏恐怕无法提供最佳的用户体验。要解决这个问题,我会使用另外一个布局文件,它带有 w960dp 限定符,表明它适用于屏幕更大、更宽的设备。


    这个布局文件与默认的 activity_main 布局相类似,其中已经包含了 ToolbarFragmentContainerView。我需要添加 NavigationView,并且将 nav_drawer_menu 设置为 NavigationViewmenu 属性。接下来,我将在 NavigationViewFragmentContainerView 之间添加分隔符。


    <RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.android.samples.donuttracker.MainActivity">

    <com.google.android.material.navigation.NavigationView
    android:id="@+id/nav_view"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_alignParentStart="true"
    app:elevation="0dp"
    app:menu="@menu/nav_drawer_menu" />

    <View
    android:layout_width="1dp"
    android:layout_height="match_parent"
    android:layout_toEndOf="@id/nav_view"
    android:background="?android:attr/listDivider" />

    <androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_alignParentTop="true"
    android:background="@color/colorPrimary"
    android:layout_toEndOf="@id/nav_view"
    android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" />

    <androidx.fragment.app.FragmentContainerView
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_below="@id/toolbar"
    app:defaultNavHost="true"
    android:layout_toEndOf="@id/nav_view"
    app:navGraph="@navigation/nav_graph" />

    </RelativeLayout>

    如此一来,在宽屏幕设备上,NavigationView 会代替 BottomNavigationView 显示在屏幕上。现在布局文件已经就绪,我再创建一个 nav_drawer_menu.xml,并且将 donutListcoffeeList 作为主要的分组添加为目的页面。对于 MenuItem,我添加了 selectionFragment 作为它的目的页面。


    <menu xmlns:android="http://schemas.android.com/apk/res/android">
    <group android:id="@+id/primary">
    <item
    android:id="@id/donutList"
    android:icon="@drawable/donut_with_sprinkles"
    android:title="@string/donut_name" />

    <item
    android:id="@id/coffeeList"
    android:icon="@drawable/coffee_cup"
    android:title="@string/coffee_name" />

    </group>
    <item
    android:id="@+id/selectionFragment"
    android:title="@string/action_settings" />

    </menu>

    现在所有布局已经就绪,我们回到 MainActivity,设置抽屉式导航栏,使其能够与 NavigationController 协作。和之前针对 BottomNavigationView 所做的相类似,这里创建一个新的方法,并且调用 setupWithNavController() 函数将 navController 传入 NavigationView。为了使代码保持整洁、各个元素之间更加清晰,我们会在新的方法中实现相关操作,并且在 onCreate() 中调用该方法。


    private fun setupNavigationMenu(navController: NavController){
    val sideNavView = findViewById<NavigationView>(R.id.nav_view)
    sideNavView?.setupWithNavController(navController)
    }

    现在当我在屏幕较宽的设备上运行应用时,可以看到抽屉式导航栏已经设置了 MenuItem,并且在导航图中,MenuItem 和目的页面的 id 是相匹配的。


    △ 在屏幕较宽的设备上运行 Donut Tracker


    △ 在屏幕较宽的设备上运行 Donut Tracker


    请注意,当我切换页面的时候返回按钮会自动显示在左上角。如果您想这么做,还可以修改 AppBarConfiguration 来将 CoffeeList 添加为最顶层的目的页面。


    小结


    本次分享的内容就是这些了。Donut Tracker 应用并不需要底部标签栏或者抽屉式导航栏,但是添加了新的功能和目的页面后,NavigationUI 可以很大程度上帮助我们处理应用中的导航功能。


    我们无需进行多余的操作,仅需添加 UI 组件,并且匹配 MenuItem 的 id 和目的页面的 id。您可以查阅 完整代码,并且通过 main 与 starter 分支的 比较,观察代码的变化。

    收起阅读 »

    iOS进阶之NSNotification的实现原理

    一、NSNotification使用1、向观察者中心添加观察者:方式一:观察者接收到通知后执行任务的代码在发送通知的线程中执行- (void)addObserver:(id)observer selector:(SEL)aSelector name:(null...
    继续阅读 »

    一、NSNotification使用

    1、向观察者中心添加观察者:

    • 方式一:观察者接收到通知后执行任务的代码在发送通知的线程中执行
    - (void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject;

    • 方式二:观察者接受到通知后执行任务的代码在指定的操作队列中执行
    - (id <NSObject>)addObserverForName:(nullable NSNotificationName)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block

    2、通知中心向观察者发送消息


    - (void)postNotification:(NSNotification *)notification;

    - (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject;

    - (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject userInfo:(nullable NSDictionary *)aUserInfo;

    3、移除观察者


    - (void)removeObserver:(id)observer;
    - (void)removeObserver:(id)observer name:(nullable NSNotificationName)aName object:(nullable id)anObject;

    二、实现原理

    1、首先了解Observation、NCTable这个结构体内部结构

    当你调用addObserver:selector:name:object:会创建一个Observation,Observation的结构如下代码:

    typedef struct  Obs {
    id observer; //接受消息的对象
    SEL selector; //执行的方法
    struct Obs *next; //下一Obs节点指针
    int retained; //引用计数
    struct NCTbl *link; //执向chunk table指针
    } Observation;

    对于Observation持有observer:

    • 在iOS9以前:

      • 持有的是一个__unsafe_unretain指针对象,当对象释放时,会访问已经释放的对象,造成BAD_ACCESS。
      • 在iOS9之后:持有的是weak类型指针,当observer释放时observer会置nil,nil对象performSelector不再会崩溃。
    • name和Observation是映射关系。

      • observer和sel包含在Observation结构体中。

    Observation对象存在哪?

    NSNotification维护了全局对象表NCTable结构,结构体里包含GSIMapTable表的结构,用于存储Observation。代码如下:

    #define CHUNKSIZE   128
    #define CACHESIZE 16
    typedef struct NCTbl {
    Observation *wildcard; /* Get ALL messages. */
    GSIMapTable nameless; /* Get messages for any name. */
    GSIMapTable named; /* Getting named messages only. */
    unsigned lockCount; /* Count recursive operations. */
    NSRecursiveLock *_lock; /* Lock out other threads. */
    Observation *freeList;
    Observation **chunks;
    unsigned numChunks;
    GSIMapTable cache[CACHESIZE];
    unsigned short chunkIndex;
    unsigned short cacheIndex;
    } NCTable;

    数据结构重要的参数:

    • wildcard:保存既没有通知名称又没有传入object的通知单链表;
    • nameless:存储没有传入名字的通知名称的hash表。
    • named:存储传入了名字的通知的hash表。
    • cache:用于快速缓存.

    这里值得注意nameless和named的结构,虽然都是hash表,存储的东西还有点区别:

    • nameless表中的GSIMapTable的结构如下

    keyvalue
    objectObservation
    objectObservation
    objectObservation

    没有传入名字的nameless表,key就是object参数,vaule为Observation结构体

    • 在named表中GSIMapTable结构如下:
    keyvalue
    namemaptable
    namemaptable
    namemaptable
    • maptable也是一个hash表,结构如下:
    keyvalue
    objectObservation
    objectObservation
    objectObservation

    传入名字的通知是存放在叫named的hash表
    kay为name,value还是maptable也是一个hash表
    maptable表的key为object参数,value为Observation参数

    2、addObserver:selector:name:object: 方法内部实现原理

    - (void) addObserver: (id)observer
    selector: (SEL)selector
    name: (NSString*)name
    object: (id)object
    {
    Observation *list;
    Observation *o;
    GSIMapTable m;
    GSIMapNode n;

    //入参检查异常处理
    ...
    //table加锁保持数据一致性,同一个线程按顺序执行,是同步的
    lockNCTable(TABLE);
    //创建Observation对象包装相应的调用函数
    o = obsNew(TABLE, selector, observer);
    //处理存在通知名称的情况
    if (name)
    {
    //table表中获取相应name的节点
    n = GSIMapNodeForKey(NAMED, (GSIMapKey)(id)name);
    if (n == 0)
    {
    //未找到相应的节点,则创建内部GSIMapTable表,以name作为key添加到talbe中
    m = mapNew(TABLE);
    name = [name copyWithZone: NSDefaultMallocZone()];
    GSIMapAddPair(NAMED, (GSIMapKey)(id)name, (GSIMapVal)(void*)m);
    GS_CONSUMED(name)
    }
    else
    {
    //找到则直接获取相应的内部table
    m = (GSIMapTable)n->value.ptr;
    }

    //内部table表中获取相应object对象作为key的节点
    n = GSIMapNodeForSimpleKey(m, (GSIMapKey)object);
    if (n == 0)
    {
    //不存在此节点,则直接添加observer对象到table中
    o->next = ENDOBS;//单链表observer末尾指向ENDOBS
    GSIMapAddPair(m, (GSIMapKey)object, (GSIMapVal)o);
    }
    else
    {
    //存在此节点,则获取并将obsever添加到单链表observer中
    list = (Observation*)n->value.ptr;
    o->next = list->next;
    list->next = o;
    }
    }
    //只有观察者对象情况
    else if (object)
    {
    //获取对应object的table
    n = GSIMapNodeForSimpleKey(NAMELESS, (GSIMapKey)object);
    if (n == 0)
    {
    //未找到对应object key的节点,则直接添加observergnustep-base-1.25.0
    o->next = ENDOBS;
    GSIMapAddPair(NAMELESS, (GSIMapKey)object, (GSIMapVal)o);
    }
    else
    {
    //找到相应的节点则直接添加到链表中
    list = (Observation*)n->value.ptr;
    o->next = list->next;
    list->next = o;
    }
    }
    //处理即没有通知名称也没有观察者对象的情况
    else
    {
    //添加到单链表中
    o->next = WILDCARD;
    WILDCARD = o;
    }
    //解锁
    unlockNCTable(TABLE);
    }

    添加通知的基本逻辑:

    1. 根据传入的selector和observer创建Observation,并存入GSIMaptable中,如果已存在,则是从cache中取。

    2. 如果name存在:

      • 则向named表中插入元素,key为name,value为GSIMaptable。
      • GSIMaptable里面key为object,value为Observation,结束
    3. 如果name不存在:

      • 则向nameless表中插入元素,key为object,value为Observation,结束
    4. 如果name和object都不存在,则把这个Observation添加WILDCARD链表中

    三、addObserverForName:object:queueusingBlock:实现原理


    //对于block形式,里面创建了GSNotificationObserver对象,然后在调用addObserver: selector: name: object:
    - (id) addObserverForName: (NSString *)name
    object: (id)object
    queue: (NSOperationQueue *)queue
    usingBlock: (GSNotificationBlock)block
    {
    GSNotificationObserver *observer =
    [[GSNotificationObserver alloc] initWithQueue: queue block: block];

    [self addObserver: observer
    selector: @selector(didReceiveNotification:)
    name: name
    object: object];

    return observer;
    }

    /*
    1.初始化该队列会创建Block_copy 拷贝block
    2.并确定通知操作队列
    */

    - (id) initWithQueue: (NSOperationQueue *)queue
    block: (GSNotificationBlock)block
    {
    self = [super init];
    if (self == nil)
    return nil;

    ASSIGN(_queue, queue);
    _block = Block_copy(block);
    return self;
    }

    /*
    1.通知的接受处理函数didReceiveNotification,
    2.如果queue不为空,通过addOperation来实现指定操作队列处理
    3.如果queue不为空,直接在当前线程执行block。
    */

    - (void) didReceiveNotification: (NSNotification *)notif
    {
    if (_queue != nil)
    {
    GSNotificationBlockOperation *op = [[GSNotificationBlockOperation alloc]
    initWithNotification: notif block: _block];

    [_queue addOperation: op];
    }
    else
    {
    CALL_BLOCK(_block, notif);
    }
    }

    4、发送通知的实现 postNotificationName: name: object:

     - (void) _postAndRelease: (NSNotification*)notification
    {
    1.入参检查校验
    2.创建存储所有匹配通知的数组GSIArray
    3.加锁table避免数据一致性问题
    4.查找既不监听name也不监听object所有的wildcard类型的Observation,加入数组GSIArray中
    5.查找NAMELESS表中指定对应观察者对象object的Observation并添加到数组中
    6.查找NAMED表中相应的Observation并添加到数组中
    1. 首先查找name与object的一致的Observation加入数组中
    2.object为nil的Observation加入数组中
    3.object不为nil,并且object和发送通知的object不一致不为添加到数组中
    //解锁table
    //遍历整个数组并依次调用performSelector:withObject处理通知消息发送
    //解锁table并释放资源
    }

    二、NSNotification相关问题

    1、对于addObserver方法,为什么需要object参数?

    1. addObserver当你不传入name也可以,传入object,当postNotification方法同样发出这个object时,就会触发通知方法。

    因为当name不存在的时候,会继续判断object,则向nameless的maptable表中插入元素,key为object,value为Observation

    2、都传入null对象会怎么样

    你可能也注意到了,addObserver方法name和object都可以为空,这表示将会把observer赋值为 wildcard,他将会监听所有的通知。

    3、通知的发送时同步的,还是异步的。

    同步异步这个问题,由于TABLE资源的问题,同一个线程会按顺序遍历数组执行,自然是同步的。

    4、NSNotificationCenter接受消息和发送消息是在一个线程里吗?如何异步发送消息

    由于是使用的performSelector方法,没有进行转线程,默认是postNotification方法的线程。


    [o->observer performSelector: o->selector 
    withObject: notification];

    对于异步发送消息,可以使用NSNotificationQueue,queue顾明意思,我们是需要将NSNotification放入queue中执行的。

    NSNotificationQueue发送消息的三种模式:

    typedef NS_ENUM(NSUInteger, NSPostingStyle) {
    NSPostWhenIdle = 1, // 当runloop处于空闲状态时post
    NSPostASAP = 2, // 当当前runloop完成之后立即post
    NSPostNow = 3 // 立即post
    };

    NSNotification *notification = [NSNotification notificationWithName:@"111" object:nil];
    [[NSNotificationQueue defaultQueue] enqueueNotification: notification postingStyle:NSPostASAP];
    • NSPostingStyle为NSPostNow 模式是同步发送,
    • NSPostWhenIdle或者NSPostASAP是异步发送

    5、NSNotificationQueue和runloop的关系?

    NSNotificationQueue 是依赖runloop才能成功触发通知,如果去掉runloop的方法,你会发现无法触发通知。


    dispatch_async(dispatch_get_global_queue(0, 0), ^{
    //子线程的runloop需要自己主动开启
    NSNotification *notification = [NSNotification notificationWithName:@"TEST" object:nil];
    [[NSNotificationQueue defaultQueue] enqueueNotification:notification postingStyle:NSPostWhenIdle coalesceMask:NSNotificationNoCoalescing forModes:@[NSDefaultRunLoopMode]];
    // run runloop
    [[NSRunLoop currentRunLoop] addPort:[NSPort port] forMode:NSRunLoopCommonModes];
    CFRunLoopRun();
    NSLog(@"3");
    });
    NSNotification *notification = [NSNotification notificationWithName:@"111" object:nil];
    [[NSNotificationQueue defaultQueue] enqueueNotification: notification postingStyle:NSPostASAP];
    NSNotificationQueue将通知添加到队列中时,其中postringStyle参数就是定义通知调用和runloop状态之间关系。


    6、如何保证通知接收的线程在主线程?

    1. 保证主线程发送消息或者接受消息方法里切换到主线程

    2. 接收到通知后跳转到主线程,苹果建议使用NSMachPort进行消息转发到主线程。

    实现代码如下:




    7、页面销毁时不移除通知会崩溃吗?

    在iOS9之前会,iOS9之后不会

    对于Observation持有observer

    在iOS9之前:不是一个类似OC中的weak类型,持有的相当与一个__unsafe_unretain指针对象,当对象释放时,会访问已经释放的对象,造成BAD_ACCESS。
    在iOS9之后:持有的是weak类型指针,对nil对象performSelector不再会崩溃

    8、多次添加同一个通知会是什么结果?多次移除通知呢?

    1. 由于源码中并不会进行重复过滤,所以添加同一个通知,等于就是添加了2次,回调也会触发两次。

    2. 关于多次移除,并没有问题,因为会去map中查找,找到才会删除。当name和object都为nil时,会移除所有关于该observer的WILDCARD

    9、下面的方式能接收到通知吗?为什么

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleNotification:) name:@"TestNotification" object:@1];

    [NSNotificationCenter.defaultCenter postNotificationName:@"TestNotification" object:nil];

    根据postNotification的实现:

    • 会找到key为TestNotification的maptable,
    • 再从中选择key为nil的observation,
    • 所以是找不到以@1为key的observation的


    作者:枫叶无处漂泊
    链接:https://www.jianshu.com/p/e93b81fd3aa9




    收起阅读 »

    iOS之响应链和事件传递,编程中的六大设计原则

    一、单一职责原则简单的讲就是一个类只做一件事,例如:CALayer:动画和视图的显示。UIView:只负责事件传递、事件响应。二、开闭原则对修改关闭,对扩展开放要考虑到后续的扩展性,而不是在原有的基础上来回修改三、接口隔离原则使用多个专门的协议,而不是一个庞大...
    继续阅读 »

    一、单一职责原则

    简单的讲就是一个类只做一件事,例如:

    • CALayer:动画和视图的显示。

    • UIView:只负责事件传递、事件响应。

    二、开闭原则

    • 对修改关闭,对扩展开放

    • 要考虑到后续的扩展性,而不是在原有的基础上来回修改

    三、接口隔离原则

    使用多个专门的协议,而不是一个庞大臃肿的基础上来回修改,例如:

    • UITableviewDelegate : 主要提供一些可选的方法,用来控制tableView的选择、指定section的头和尾的显示以及协助完成cell的删除和排序等功能。

    • UITableViewDataSource : 主要为UITableView提供显示用的数据(UITableViewCell),指定UITableViewCell支持的编辑操作类型(insert,delete和 reordering),并根据用户的操作进行相应的数据更新操作

    四、依赖倒置原则

    • 抽象不应该依赖于具体实现,具体实现可以依赖于抽象。
    • 调用接口感觉不到内部是如何操作的

    五、里氏替换原则

    父类可以被子类无缝替换,且原有的功能不受任何影响,例如:KVO
    继承父类,也不想使用使用KVO监听对象的属性。

    六、迪米特法则

    一个对象应当对其他对象尽可能少的了解,实现高聚合、低耦合


    前言

    首先要先学习下响应者对象UIResponder,只有继承UIResponder的的类,才能处理事件。

    @interface UIApplication : UIResponder

    @interface UIView : UIResponder

    @interface UIViewController : UIResponder

    @interface UIWindow : UIView

    @interface UIControl : UIView

    @interface CALayer : NSObject <NSSecureCoding, CAMediaTiming>


    我们可以看出UIApplication、UIView、UIWindow->UIView、UIViewController都是继承自UIResponder类,可以响应和处理事件。CALayer不是UIResponder的子类,无法处理事件。

    UIControl是UIView的子类,当然也是UIResponder的子类。UIControl是诸如UIButton,UISwitch,UItextField等控件的父类,它本身包含了一些属性和方法,但是不能直接食用UIControl类,他只是定义了子类都需要使用的方法。

    我们有时候可能通过UIReponsder的nextResponder来查找控件的父视图控件


    // 通过遍历button上的响应链来查找cell
    UIResponder *responder = button.nextResponder;
    while (responder) {
    if ([responder isKindOfClass:[SWSwimCircleItemTableViewCell class]]) {
    SWSwimCircleItemTableViewCell *cell = (SWSwimCircleItemTableViewCell *)responder;
    break;
    }
    responder = responder.nextResponder;
    }
    }

    UIControl 与 UIView的关系和区别

    • UIControl继承与UIView,在UIView基础上侧重于事件交互,最大的特点就是拥有addTaget:action:forcontrolEvents方法

    • UIVew侧重于页面布局,所以没有时间交互的方法,可以通过添加手势来实现

    事件UIEvent

    对于IOS设备用户来说,他们的事件类型分为三种:

    1. 触摸事件(Touch Event)

    2. 运动事件 (Motion Event)

    3. 远端控制事件 (Remote-Control Event)

    今天以触屏事件(Touch Event)为例,来说明在Cocoa Touch框架中,事件的处理流程。

    事件的传递和响应过程

    1. 点击屏幕后,经过系统的一系列处理,我们的应用接收到source0事件,并从事件队列中取出事件对象,开始寻找真正响应事件的视图。

    2. UIApplication将处于任务队列最前端的事件向下分发。即UIWindow。

    3. UIWindow将事件向下分发,即UIView。

    4. UIView首先看自己是否能处理事件,触摸点是否在自己身上。如果能,那么继续寻找子视图。

    5. 遍历子控件,重复3、4步骤

    6. 如果没有找到,那么自己就是事件处理者

    7. 如果自己不能处理,那么不做任何处理

    其中 UIView不接受事件处理的情况主要有以下三种:

    1. alpha <0.01

    2. userInteractionEnabled = NO

    3. hidden = YES.

    从父控件到子控件寻找处理事件最合适view的过程。

    如果父视图不接受事件处理(上面三种情况),则子视图也不能接收事件。

    事件只要触摸了就会产生,关键在于是否有最合适的view来处理和接收事件,如果遍历到最后都没有最合适的view来接收事件,则该事件被废弃。

    响应者寻找过程分析

    寻找相应过程主要涉及到两个方法:

    //判断点击的位置是不是在视图内
    - (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event;

    //此方法返回的View是本次点击事件需要的最佳View(第一响应者)
    - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event;

    因为所有的视图类都是继承UIView,在UIView(UIViewGeometry)类别里实现这个方法,代码大概的实现流程:

    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    // 1.判断当前控件能否接收事件
    if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) return nil;
    // 2. 判断点在不在当前控件
    if ([self pointInside:point withEvent:event] == NO) return nil;
    // 3.从后往前遍历自己的子控件
    NSInteger count = self.subviews.count;
    for (NSInteger i = count - 1; i >= 0; i--) {
    UIView *childView = self.subviews[I];
    // 把当前控件上的坐标系转换成子控件上的坐标系
    CGPoint childP = [self convertPoint:point toView:childView];
    UIView *fitView = [childView hitTest:childP withEvent:event];
    if (fitView) { // 寻找到最合适的view
    return fitView;
    }
    }
    // 循环结束,表示没有比自己更合适的view
    return self;

    }

    看着上面的代码:

    • 首先该view的hidden = YES、userInteractionEnabled=YES、alpha<0.01 三种情况成立一个就直接返回nil,代表视图无法继续寻找最合适的view

    • 其次判断触摸点在不在当前控件,不在也是返回nil

    • 最后倒序遍历子视图,把当前控件上的坐标系转成子控件上的坐标系,判断子视图能够响应,有的话说明在子视图寻找到最合适的view。

    • 如果都没有的话,循环结束,表示没有比自己更合适的view,返回自己的view

    下面就通过一个例子来探寻整个寻找的过程

    我们在ViewController构造一个简单的视图层级,BlueView、YellowView是两个根节点视图,RedView是他们的父视图。效果如下:





    步骤1,2,3

    结合上面两张图,介绍一下整个执行流程:

    1. 先从UIWindow视图开启,因此,对UIWindow对象进行hitTest: withEvent:在方法内使用pointInside:withEvent:方法判断用户点击的范围是在UIWindow的范围内,显然pointInside:withEvent:返回了YES,这时候继续检查子视图

    2. 第二步和第三步骤重复第一步的操作,pointInside:withEvent:返回的都是YES,下面对RedView里继续检查自视图是否响应该事件

    3. 遍历RedView子视图,如果先遍历的YellowView,对YellowView进行 hitTest: withEvent:里面做pointInside:withEvent判断,不在点击范围内返回NO,对应的hitTest:withEvent:返回nil;

    4. 继续遍历RedView子视图BlueView,对BlueView hitTest: withEvent:里面做pointInside:withEvent判断,发现在点击范围内返回YES.

    5. 由于BlueView没有子视图(也可以理解成对的BlueView子视图进行hitTest时返回了nil),因此,BlueView的hitTest:withEvent:会将BlueView返回,再往回回溯。

    6. ReadView的hitTest:withEvent返回的BlueView -> UIView的hitTest:withEvent返回的BlueView -> UIWindow的hitTest:withEvent返回的BlueView。

    7. UIWindow的nexResponder指向UIApplication最后指向AppDelegate。

    至此,本次点击事件的第一响应者就通过响应者链的事件分发逻辑成功的找到了。

    不难看出,这个处理流程有点类似二分搜索的思想,这样能以最快的速度,最精确地定位出能响应触摸事件的UIView。

    进一步说明

    • 如果hitTest:withEvent没有找到第一响应者,或者第一响应者没有处理改事件,则该事件会沿着响应者链向上回溯,如果UIWindow实例和UIApplication实例都不能处理该事件,则该事件会被丢弃。

    • hitTest:withEvent方法将会忽略以下三种情况。

      • 该view的hidden = YES

      • 该view的userInteractionEnabled=YES

      • 该view的alpha<0.01

    • 如果一个子视图的区域超过父视图的bound区域(父视图的clipsToBounds 属性为NO,这样超过父视图bound区域的子视图内容也会显示),那么正常情况下对子视图在父视图之外区域的触摸操作不会被识别。因为父视图的pointInside:withEvent:方法会返回NO,这样就不会继续向下遍历子视图了。

    当然,也可以重写pointInside:withEvent:方法来处理这种情况。

    我们可以重写hitTest:withEvent:来拦截事件传递并处理事件来达到目的,实际应用中很少用到这些。



    作者:枫叶无处漂泊
    链接:https://www.jianshu.com/p/065e39cfce8b


    收起阅读 »