注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

手写迷你版Vue

手写迷你版Vue参考代码:github.com/57code/vue-…Vue响应式设计思路Vue响应式主要包含:数据响应式监听数据变化,并在视图中更新Vue2使用Object.defineProperty实现数据劫持Vu3使用Proxy实现数据劫持模板引擎提...
继续阅读 »




手写迷你版Vue

参考代码:github.com/57code/vue-…

Vue响应式设计思路

Vue响应式主要包含:

  • 数据响应式

  • 监听数据变化,并在视图中更新

  • Vue2使用Object.defineProperty实现数据劫持

  • Vu3使用Proxy实现数据劫持

  • 模板引擎

  • 提供描述视图的模板语法

  • 插值表达式{{}}

  • 指令 v-bind, v-on, v-model, v-for,v-if

  • 渲染

  • 将模板转换为html

  • 解析模板,生成vdom,把vdom渲染为普通dom

数据响应式原理

image.png

数据变化时能自动更新视图,就是数据响应式 Vue2使用Object.defineProperty实现数据变化的检测

原理解析

  • new Vue()⾸先执⾏初始化,对data执⾏响应化处理,这个过程发⽣在Observer

  • 同时对模板执⾏编译,找到其中动态绑定的数据,从data中获取并初始化视图,这个过程发⽣在

Compile

  • 同时定义⼀个更新函数和Watcher实例,将来对应数据变化时,Watcher会调⽤更新函数

  • 由于data的某个key在⼀个视图中可能出现多次,所以每个key都需要⼀个管家Dep来管理多个

Watcher

  • 将来data中数据⼀旦发⽣变化,会⾸先找到对应的Dep,通知所有Watcher执⾏更新函数

image.png

一些关键类说明

CVue:自定义Vue类 Observer:执⾏数据响应化(分辨数据是对象还是数组) Compile:编译模板,初始化视图,收集依赖(更新函数、 watcher创建) Watcher:执⾏更新函数(更新dom) Dep:管理多个Watcher实例,批量更新

涉及关键方法说明

observe: 遍历vm.data的所有属性,对其所有属性做响应式,会做简易判断,创建Observer实例进行真正响应式处理

html页面

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cvue</title>
<script src="./cvue.js"></script>
</head>
<body>
<div id="app">
  <p>{{ count }}</p>
</div>

<script>
  const app = new CVue({
    el: '#app',
    data: {
      count: 0
    }
  })
  setInterval(() => {
    app.count +=1
  }, 1000);
</script>
</body>
</html>

CVue

  • 创建基本CVue构造函数:

  • 执⾏初始化,对data执⾏响应化处理

// 自定义Vue类
class CVue {
constructor(options) {
  this.$options = options
  this.$data = options.data

  // 响应化处理
  observe(this.$data)
}
}

// 数据响应式, 修改对象的getter,setter
function defineReactive(obj, key, val) {
// 递归处理,处理val是嵌套对象情况
observe(val)
Object.defineProperty(obj, key, {
  get() {
    return val
  },
  set(newVal) {
    if(val !== newVal) {
      console.log(`set ${key}:${newVal}, old is ${val}`)

      val = newVal
      // 继续进行响应式处理,处理newVal是对象情况
      observe(val)
    }
  }
})
}

// 遍历obj,对其所有属性做响应式
function observe(obj) {
// 只处理对象类型的
if(typeof obj !== 'object' || obj == null) {
  return
}
// 实例化Observe实例
new Observe(obj)
}

// 根据传入value的类型做相应的响应式处理
class Observe {
constructor(obj) {
  if(Array.isArray(obj)) {
    // TODO
  } else {
    // 对象
    this.walk(obj)
  }
}
walk(obj) {
  // 遍历obj所有属性,调用defineReactive进行响应化
  Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]))
}
}

为vm.$data做代理

方便实例上设置和获取数据

例如

原本应该是

vm.$data.count
vm.$data.count = 233

代理之后后,可以使用如下方式

vm.count
vm.count = 233

给vm.$data做代理

class CVue {
constructor(options) {
  // 省略
  // 响应化处理
  observe(this.$data)

  // 代理data上属性到实例上
  proxy(this)
}
}

// 把CVue实例上data对象的属性到代理到实例上
function proxy(vm) {
Object.keys(vm.$data).forEach(key => {
  Object.defineProperty(vm, key, {
    get() {
      // 实现 vm.count 取值
      return vm.$data[key]
    },
    set(newVal) {
      // 实现 vm.count = 123赋值
      vm.$data[key] = newVal
    }
  })
})
}

编译

image.png

初始化视图

根据节点类型进行编译
class CVue {
constructor(options) {
  // 省略。。
  // 2 代理data上属性到实例上
  proxy(this)

  // 3 编译
  new Compile(this, this.$options.el)
}
}

// 编译模板中vue语法,初始化视图,更新视图
class Compile {
constructor(vm, el) {
  this.$vm = vm
  this.$el = document.querySelector(el)

  if(this.$el) {
    this.complie(this.$el)
  }
}
// 编译
complie(el) {
  // 取出所有子节点
  const childNodes = el.childNodes
  // 遍历节点,进行初始化视图
  Array.from(childNodes).forEach(node => {
    if(this.isElement(node)) {
      // TODO
      console.log(`编译元素 ${node.nodeName}`)
    } else if(this.isInterpolation(node)) {
      console.log(`编译插值文本 ${node.nodeName}`)
    }
    // 递归编译,处理嵌套情况
    if(node.childNodes) {
      this.complie(node)
    }
  })
}
// 是元素节点
isElement(node) {
  return node.nodeType === 1
}
// 是插值表达式
isInterpolation(node) {
  return node.nodeType === 3
    && /\{\{(.*)\}\}/.test(node.textContent)
}
}
编译插值表达式
// 编译模板中vue语法,初始化视图,更新视图
class Compile {
complie(el) {
  Array.from(childNodes).forEach(node => {
    if(this.isElement(node)) {
      console.log(`编译元素 ${node.nodeName}`)
    } else if(this.isInterpolation(node)) {
      // console.log(`编译插值文本 ${node.textContent}`)
      this.complieText(node)
    }
    // 省略
  })
}
// 是插值表达式
isInterpolation(node) {
  return node.nodeType === 3
    && /\{\{(.*)\}\}/.test(node.textContent)
}
// 编译插值
complieText(node) {
  // RegExp.$1是isInterpolation()中/\{\{(.*)\}\}/匹配出来的组内容
  // 相等于{{ count }}中的count
  const exp = String(RegExp.$1).trim()
  node.textContent = this.$vm[exp]
}
}
编译元素节点和指令

需要取出指令和指令绑定值 使用数据更新视图

// 编译模板中vue语法,初始化视图,更新视图
class Compile {
complie(el) {
  Array.from(childNodes).forEach(node => {
    if(this.isElement(node)) {
      console.log(`编译元素 ${node.nodeName}`)
      this.complieElement(node)
    }
    // 省略
  })
}
// 是元素节点
isElement(node) {
  return node.nodeType === 1
}
// 编译元素
complieElement(node) {
  // 取出元素上属性
  const attrs = node.attributes
  Array.from(attrs).forEach(attr => {
    // c-text="count"中c-text是attr.name,count是attr.value
    const { name: attrName, value: exp } = attr
    if(this.isDirective(attrName)) {
      // 取出指令
      const dir = attrName.substring(2)
      this[dir] && this[dir](node, exp)
    }
  })
}
// 是指令
isDirective(attrName) {
  return attrName.startsWith('')
}
// 处理c-text文本指令
text(node, exp) {
  node.textContent = this.$vm[exp]
}
// 处理c-html指令
html(node, exp) {
  node.innerHTML = this.$vm[exp]
}
}

以上完成初次渲染,但是数据变化后,不会触发页面更新

依赖收集

视图中会⽤到data中某key,这称为依赖。 同⼀个key可能出现多次,每次出现都需要收集(⽤⼀个Watcher来维护维护他们的关系),此过程称为依赖收集。 多个Watcher需要⼀个Dep来管理,需要更新时由Dep统⼀通知。

image.png

  • data中的key和dep是一对一关系

  • 视图中key出现和Watcher关系,key出现一次就对应一个Watcher

  • dep和Watcher是一对多关系

实现思路

  • defineReactive中为每个key定义一个Dep实例

  • 编译阶段,初始化视图时读取key, 会创建Watcher实例

  • 由于读取过程中会触发key的getter方法,便可以把Watcher实例存储到key对应的Dep实例

  • 当key更新时,触发setter方法,取出对应的Dep实例Dep实例调用notiy方法通知所有Watcher更新

定义Watcher类

监听器,数据变化更新对应节点视图

// 创建Watcher监听器,负责更新视图
class Watcher {
// vm vue实例,依赖key,updateFn更新函数(编译阶段传递进来)
constructor(vm, key, updateFn) {
  this.$vm = vm
  this.$key = key
  this.$updateFn = updateFn
}
update() {
  // 调用更新函数,获取最新值传递进去
  this.$updateFn.call(this.$vm, this.$vm[this.$key])
}
}
修改Compile类中的更新函数,创建Watcher实例
class Complie {
// 省略。。。
// 编译插值
complieText(node) {
  // RegExp.$1是isInterpolation()中/\{\{(.*)\}\}/匹配出来的组内容
  // 相等于{{ count }}中的count
  const exp = String(RegExp.$1).trim()
  // node.textContent = this.$vm[exp]
  this.update(node, exp, 'text')
}
// 处理c-text文本指令
text(node, exp) {
  // node.textContent = this.$vm[exp]
  this.update(node, exp, 'text')
}
// 处理c-html指令
html(node, exp) {
  // node.innerHTML = this.$vm[exp]
  this.update(node, exp, 'html')
}
// 更新函数
update(node, exp, dir) {
  const fn = this[`${dir}Updater`]
  fn && fn(node, this.$vm[exp])

  // 创建监听器
  new Watcher(this.$vm, exp, function(newVal) {
    fn && fn(node, newVal)
  })
}
// 文本更新器
textUpdater(node, value) {
  node.textContent = value
}
// html更新器
htmlUpdater(node, value) {
  node.innerHTML = value
}
}
定义Dep类
  • data的一个属性对应一个Dep实例

  • 管理多个Watcher实例,通知所有Watcher实例更新

// 创建订阅器,每个Dep实例对应data中的一个属性
class Dep {
constructor() {
  this.deps = []
}
// 添加Watcher实例
addDep(dep) {
  this.deps.push(dep)
}
notify() {
  // 通知所有Wather更新视图
  this.deps.forEach(dep => dep.update())
}
}
创建Watcher时触发getter
class Watcher {
// vm vue实例,依赖key,updateFn更新函数(编译阶段传递进来)
constructor(vm, key, updateFn) {
  // 省略
  // 把Wather实例临时挂载在Dep.target上
  Dep.target = this
  // 获取一次属性,触发getter, 从Dep.target上获取Wather实例存放到Dep实例中
  this.$vm[key]
  // 添加后,重置Dep.target
  Dep.target = null
}
}
defineReactive中作依赖收集,创建Dep实例
function defineReactive(obj, key, val) {
// 递归处理,处理val是嵌套对象情况
observe(val)

const dep = new Dep()
Object.defineProperty(obj, key, {
  get() {
    Dep.target && dep.addDep(Dep.target)
    return val
  },
  set(newVal) {
    if(val !== newVal) {
      val = newVal
      // 继续进行响应式处理,处理newVal是对象情况
      observe(val)
      // 更新视图
      dep.notify()
    }
  }
})
}

监听事件指令@xxx

  • 在创建vue实例时,需要缓存methods到vue实例上

  • 编译阶段取出methods挂载到Compile实例上

  • 编译元素时

  • 识别出v-on指令时,进行事件的绑定

  • 识别出@属性时,进行事件绑定

  • 事件绑定:通过指令或者属性获取对应的函数,给元素新增事件监听,使用bind修改监听函数的this指向为组件实例

// 自定义Vue类
class CVue {
constructor(options) {
  this.$methods = options.methods
}
}

// 编译模板中vue语法,初始化视图,更新视图
class Compile {
constructor(vm, el) {
  this.$vm = vm
  this.$el = document.querySelector(el)
  this.$methods = vm.$methods
}

// 编译元素
complieElement(node) {
  // 取出元素上属性
  const attrs = node.attributes
  Array.from(attrs).forEach(attr => {
    // c-text="count"中c-text是attr.name,count是attr.value
    const { name: attrName, value: exp } = attr
    if(this.isDirective(attrName)) {
      // 省略。。。
      if(this.isEventListener(attrName)) {
        // v-on:click, subStr(5)即可截取到click
        const eventType = attrName.substring(5)
        this.bindEvent(eventType, node, exp)
      }
    } else if(this.isEventListener(attrName)) {
      // @click, subStr(1)即可截取到click
      const eventType = attrName.substring(1)
      this.bindEvent(eventType, node, exp)
    }
  })
}
// 是事件监听
isEventListener(attrName) {
  return attrName.startsWith('@') || attrName.startsWith('c-on')
}
// 绑定事件
bindEvent(eventType, node, exp) {
  // 取出表达式对应函数
  const method = this.$methods[exp]
  // 增加监听并修改this指向当前组件实例
  node.addEventListener(eventType, method.bind(this.$vm))
}
}

v-model双向绑定

实现v-model绑定input元素时的双向绑定功能

// 编译模板中vue语法,初始化视图,更新视图
class Compile {
// 省略...
// 处理c-model指令
model(node, exp) {
  // 渲染视图
  this.update(node, exp, 'model')
  // 监听input变化
  node.addEventListener('input', (e) => {
    const { value } = e.target
    // 更新数据,相当于this.username = 'mio'
    this.$vm[exp] = value
  })
}
// model更新器
modelUpdater(node, value) {
  node.value = value
}
}

数组响应式

  • 获取数组原型

  • 数组原型创建对象作为数组拦截器

  • 重写数组的7个方法

// 数组响应式
// 获取数组原型, 后面修改7个方法
const originProto = Array.prototype
// 创建对象做备份,修改响应式都是在备份的上进行,不影响原始数组方法
const arrayProto = Object.create(originProto)
// 拦截数组方法,在变更时发出通知
;['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
// 在备份的原型上做修改
arrayProto[method] = function() {
  // 调用原始操作
  originProto[method].apply(this, arguments)
  // 发出变更通知
  console.log(`method:${method} value:${Array.from(arguments)}`)
}
})

class Observe {
constructor(obj) {
  if(Array.isArray(obj)) {
    // 修改数组原型为自定义的
    obj.__proto__ = arrayProto
    this.observeArray(obj)
  } else {
    // 对象
    this.walk(obj)
  }
}
observeArray(items) {
  // 如果数组内部元素时对象,继续做响应化处理
  items.forEach(item => observe(item))
}
}

作者:LastStarDust
来源:https://juejin.cn/post/7036291383153393701

收起阅读 »

LRU缓存-keep-alive实现原理

相信大部分同学在日常需求开发中或多或少的会有需要一个组件状态被持久化、不被重新渲染的场景,熟悉 vue 的同学一定会想到 keep-alive 这个内置组件。 keep-alive 是 Vue.js 的一个 内置组件。它能够将不活动的组件实例保存在内存中,而不...
继续阅读 »



前言

相信大部分同学在日常需求开发中或多或少的会有需要一个组件状态被持久化、不被重新渲染的场景,熟悉 vue 的同学一定会想到 keep-alive 这个内置组件。

那么什么是 keep-alive 呢?

keep-alive 是 Vue.js 的一个 内置组件。它能够将不活动的组件实例保存在内存中,而不是直接将其销毁,它是一个抽象组件,不会被渲染到真实 DOM 中,也不会出现在父组件链中。简单的说,keep-alive用于保存组件的渲染状态,避免组件反复创建和渲染,有效提升系统性能。 keep-alivemax 属性,用于限制可以缓存多少组件实例,一旦这个数字达到了上限,在新实例被创建之前,已缓存组件中最久没有被访问的实例会被销毁掉,而这里所运用到的缓存机制就是 LRU 算法

LRU 缓存淘汰算法

LRU( least recently used)根据数据的历史记录来淘汰数据,重点在于保护最近被访问/使用过的数据,淘汰现阶段最久未被访问的数据

LRU的主体思想在于:如果数据最近被访问过,那么将来被访问的几率也更高

fifo对比lru原理

  1. 新数据插入到链表尾部;

  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表尾部

  3. 当链表满的时候,将链表头部的数据丢弃。

实现LRU的数据结构

经典的 LRU 一般都使用 hashMap + 双向链表。考虑可能需要频繁删除一个元素,并将这个元素的前一个节点指向下一个节点,所以使用双链接最合适。并且它是按照结点最近被使用的时间顺序来存储的。 如果一个结点被访问了, 我们有理由相信它在接下来的一段时间被访问的概率要大于其它结点。

map.keys()

不过既然已经在 js 里都已经使用 Map 了,何不直接取用现成的迭代器获取下一个结点的 key 值(keys().next( )

// ./LRU.ts
export class LRUCache {
capacity: number; // 容量
cache: Map; // 缓存
constructor(capacity: number) {
  this.capacity = capacity;
  this.cache = new Map();
}
get(key: number): number {
  if (this.cache.has(key)) {
    let temp = this.cache.get(key) as number;
    //访问到的 key 若在缓存中,将其提前
    this.cache.delete(key);
    this.cache.set(key, temp);
    return temp;
  }
  return -1;
}
put(key: number, value: number): void {
  if (this.cache.has(key)) {
    this.cache.delete(key);
    //存在则删除,if 结束再提前
  } else if (this.cache.size >= this.capacity) {
    // 超过缓存长度,淘汰最近没使用的
    this.cache.delete(this.cache.keys().next().value);
    console.log(`refresh: key:${key} , value:${value}`)
  }
  this.cache.set(key, value);
}
toString(){
  console.log('capacity',this.capacity)
  console.table(this.cache)
}
}
// ./index.ts
import {LRUCache} from './lru'
const list = new LRUCache(4)
list.put(2,2)   // 2,剩余容量3
list.put(3,3)   // 3,剩余容量2
list.put(4,4)   // 4,剩余容量1
list.put(5,5)   // 5,已满   从头至尾         2-3-4-5
list.put(4,4)   // 入4,已存在 ——> 置队尾         2-3-5-4
list.put(1,1)   // 入1,不存在 ——> 删除队首 插入1 3-5-4-1
list.get(3)     // 获取3,刷新3——> 置队尾         5-4-1-3
list.toString()
// ./index.ts
import {LRUCache} from './lru'
const list = new LRUCache(4)

list.put(2,2)   // 2,剩余容量3
list.put(3,3)   // 3,剩余容量2
list.put(4,4)   // 4,剩余容量1
list.put(5,5)   // 5,已满   从头至尾 2-3-4-5
list.put(4,4)   // 入4,已存在 ——> 置队尾 2-3-5-4
list.put(1,1)   // 入1,不存在 ——> 删除队首 插入1 3-5-4-1
list.get(3)     // 获取3,刷新3——> 置队尾 5-4-1-3
list.toString()

结果如下: lru打印结果.jpg

vue 中 Keep-Alive

原理

  1. 使用 LRU 缓存机制进行缓存,max 限制缓存表的最大容量

  2. 根据设定的 include/exclude(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例

  3. 根据组件 ID 和 tag 生成缓存 Key ,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该 key 在 this.keys 中的位置(更新 key 的位置是实现 LRU 置换策略的关键)

  4. 获取节点名称,或者根据节点 cid 等信息拼出当前 组件名称

  5. 获取 keep-alive 包裹着的第一个子组件对象及其组件名

源码分析

初始化 keepAlive 组件
const KeepAliveImpl: ComponentOptions = {
 name: `KeepAlive`,
 props: {
   include: [String, RegExp, Array],
   exclude: [String, RegExp, Array],
   max: [String, Number],
},
 setup(props: KeepAliveProps, { slots }: SetupContext) {
   // 初始化数据
   const cache: Cache = new Map();
   const keys: Keys = new Set();
   let current: VNode | null = null;
   // 当 props 上的 include 或者 exclude 变化时移除缓存
   watch(
    () => [props.include, props.exclude],
    ([include, exclude]) => {
     include && pruneCache((name) => matches(include, name));
     exclude && pruneCache((name) => !matches(exclude, name));
    },
    { flush: "post", deep: true }
  );
   // 缓存组件的子树 subTree
   let pendingCacheKey: CacheKey | null = null;
   const cacheSubtree = () => {
     // fix #1621, the pendingCacheKey could be 0
     if (pendingCacheKey != null) {
       cache.set(pendingCacheKey, getInnerChild(instance.subTree));
    }
  };
   // KeepAlive 组件的设计,本质上就是空间换时间。
   // 在 KeepAlive 组件内部,
   // 当组件渲染挂载和更新前都会缓存组件的渲染子树 subTree
   onMounted(cacheSubtree);
   onUpdated(cacheSubtree);
   onBeforeUnmount(() => {
   // 卸载缓存表里的所有组件和其中的子树...
  }
   return ()=>{
     // 返回 keepAlive 实例
  }
}
}

return ()=>{
 // 省略部分代码,以下是缓存逻辑
 pendingCacheKey = null
 const children = slots.default()
 let vnode = children[0]
 const comp = vnode.type as Component
 const name = getName(comp)
 const { include, exclude, max } = props
 // key 值是 KeepAlive 子节点创建时添加的,作为缓存节点的唯一标识
 const key = vnode.key == null ? comp : vnode.key
 // 通过 key 值获取缓存节点
 const cachedVNode = cache.get(key)
 if (cachedVNode) {
   // 缓存存在,则使用缓存装载数据
   vnode.el = cachedVNode.el
   vnode.component = cachedVNode.component
   if (vnode.transition) {
     // 递归更新子树上的 transition hooks
     setTransitionHooks(vnode, vnode.transition!)
  }
     // 阻止 vNode 节点作为新节点被挂载
     vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
     // 刷新key的优先级
     keys.delete(key)
     keys.add(key)
} else {
     keys.add(key)
     // 属性配置 max 值,删除最久不用的 key ,这很符合 LRU 的思想
     if (max && keys.size > parseInt(max as string, 10)) {
       pruneCacheEntry(keys.values().next().value)
    }
  }
   // 避免 vNode 被卸载
   vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
   current = vnode
   return vnode;
}
将组件移出缓存表
// 遍历缓存表
function pruneCache(filter?: (name: string) => boolean) {
 cache.forEach((vnode, key) => {
   const name = getComponentName(vnode.type as ConcreteComponent);
   if (name && (!filter || !filter(name))) {
     // !filter(name) 即 name 在 includes 或不在 excludes 中
     pruneCacheEntry(key);
  }
});
}
// 依据 key 值从缓存表中移除对应组件
function pruneCacheEntry(key: CacheKey) {
 const cached = cache.get(key) as VNode;
 if (!current || cached.type !== current.type) {
   /* 当前没有处在 activated 状态的组件
    * 或者当前处在 activated 组件不是要删除的 key 时
    * 卸载这个组件
  */
   unmount(cached); // unmount方法里同样包含了 resetShapeFlag
} else if (current) {
   // 当前组件在未来应该不再被 keepAlive 缓存
   // 虽然仍在 keepAlive 的容量中但是需要刷新当前组件的优先级
   resetShapeFlag(current);
   // resetShapeFlag
}
 cache.delete(key);
 keys.delete(key);
}
function resetShapeFlag(vnode: VNode) {
 let shapeFlag = vnode.shapeFlag; // shapeFlag 是 VNode 的标识
  // ... 清除组件的 shapeFlag
}

keep-alive案例

本部分将使用 vue 3.x 的新特性来模拟 keep-alive 的具体应用场景

在 index.vue 里我们引入了 CountUp 、timer 和 ColorRandom 三个带有状态的组件 在容量为 2 的 中包裹了一个动态组件

// index.vue
<script setup>
import { ref } from "vue"
import CountUp from '../components/CountUp.vue'
import ColorRandom from '../components/ColorRandom.vue'
import Timer from '../components/Timer.vue'
const tabs = ref([    // 组件列表
{
   title: "ColorPicker",
   comp: ColorRandom,
},
{
   title: "timer1",
   comp: Timer,
},
{
   title: "timer2",
   comp: Timer,
},
{
   title: "CountUp",
   comp: CountUp,
},
])
const currentTab = ref(tabs.value[0]) // tab 默认展示第一个组件
const tabSwitch = (tab) => {
 currentTab.value = tab
}
script>
<template>
 <div id="main-page">keep-alive demo belowdiv>
 <div class="tab-group">
   <button
   v-for="tab in tabs"
   :key="tab"
   :class="['tab-button', { active: currentTab === tab }]"
   @click="tabSwitch(tab)"
 >
   {{ tab.title }}
 button>
 div>
 <keep-alive max="2">
   
   <component
     v-if="currentTab"
     :is="currentTab.comp"
     :key="currentTab.title"
     :name="currentTab.title"
   />
 keep-alive>
template>

缓存状态

缓存流程如下:

缓存流程图

可以看到被包裹在 keep-alive 的动态组件缓存了前一个组件的状态。

通过观察 vue devtools 里节点的变化,可以看到此时 keepAlive 中包含了 ColorRandomTimer 两个组件,当前展示的组件会处在 activated 的状态,而其他被缓存的组件则处在 inactivated 的状态

如果我们注释了两个 keep-alive 会发现不管怎么切换组件,都只会重新渲染,并不会保留前次的状态
keepAlive-cache.gif

移除组件

移除流程如下:

移除流程图

为了验证组件是否在切换tab时能被成功卸载,在每个组件的 onUnmounted 中加上了 log

onUnmounted(()=>{
 console.log(`${props.name} 组件被卸载`)
})
  • 当缓存数据长度小于等于 max ,切换组件并不会卸载其他组件,就像上面在 vue devtools 里展示的一样,只会触发组件的 activateddeactivated 两个生命周期

  • 若此时缓存数据长度大于 max ,则会从缓存列表中删除优先级较低的,优先被淘汰的组件,对应的可以看到该组件 umounted 生命周期触发。

性能优化

使用 KeepAlive 后,被 KeepAlive 包裹的组件在经过第一次渲染后,的 vnode 以及 DOM 都会被缓存起来,然后再下一次再次渲染该组件的时候,直接从缓存中拿到对应的 vnode 和 DOM,然后渲染,并不需要再走一次组件初始化,render 和 patch 等一系列流程,减少了 script 的执行时间,性能更好。

总结

Vue 内部将 DOM 节点抽象成了一个个的 VNode 节点,keep-alive 组件的缓存也是基于 VNode 节点的而不是直接存储 DOM 结构。它将满足条件( include 与 exclude )的组件在 cache 对象中缓存起来,在需要重新渲染的时候再将 vnode 节点从 cache 对象中取出并渲染。

具体缓存过程如下:

  1. 声明有序集合 keys 作为缓存容器,存入组件的唯一 key 值

  2. 在缓存容器 keys 中,越靠前的 key 值意味着被访问的越少也越优先被淘汰

  3. 渲染函数执行时,若命中缓存时,则从 keys 中删除当前命中的 key,并往 keys 末尾追加 key 值,刷新该 key 的优先级

  4. 未命中缓存时,则 keys 追加缓存数据 key 值,若此时缓存数据长度大于 max 最大值,则删除最旧的数据

  5. 当触发 beforeMount/update 生命周期,缓存当前 activated 组件的子树的数据


参考

作者:政采云前端团队
来源:https://juejin.cn/post/7036483610920091656

收起阅读 »

Android 关键字高亮

前言项目中经常会遇到需要对关键字加特殊色值显示,不管是搜索内容还是列表关键字展示,对于特殊文字或者词组高亮是一种很常见的需求,Android 没有自带这样的工具或者组件提供,但是我们可以自己实现一个这样的工具类,用到的地方直接调用就好了。文字高亮所谓文字高亮,...
继续阅读 »

前言

项目中经常会遇到需要对关键字加特殊色值显示,不管是搜索内容还是列表关键字展示,对于特殊文字或者词组高亮是一种很常见的需求,Android 没有自带这样的工具或者组件提供,但是我们可以自己实现一个这样的工具类,用到的地方直接调用就好了。

文字高亮

所谓文字高亮,其实就是针对某个字符做特殊颜色显示,下面列举几种常见的实现方式

一、通过加载Html标签,显示高亮

Android 的TextView 可以加载带Html标签的段落,方法:

textView.setText(Html.fromHtml("<font color='red' size='24'>Hello World</font>"));

那么要高亮显示关键字,就可以这样实现,把需要高亮显示的关键字,通过这样的方式,组合起来就好了,例如:

textView.setText(“这是我的第一个安卓项目” + Html.fromHtml("<font color='red' size='24'>Hello World</font>"));

二、通过SpannableString来实现文本高亮

先来简单了解下SpannableString

SpannableString的基本使用代码示例:

//设置Url地址连接
private void addUrlSpan() {
SpannableString spanString = new SpannableString("超链接");
URLSpan span = new URLSpan("tel:0123456789");
spanString.setSpan(span, 0, 3, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.append(spanString);
}

//设置字体背景的颜色
private void addBackColorSpan() {
SpannableString spanString = new SpannableString("文字背景颜色");
BackgroundColorSpan span = new BackgroundColorSpan(Color.YELLOW);
spanString.setSpan(span, 0, 3, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.append(spanString);
}
//设置字体的颜色
private void addForeColorSpan() {
SpannableString spanString = new SpannableString("文字前景颜色");
ForegroundColorSpan span = new ForegroundColorSpan(Color.BLUE);
spanString.setSpan(span, 0, 3, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.append(spanString);
}
//设置字体的大小
private void addFontSpan() {
SpannableString spanString = new SpannableString("36号字体");
AbsoluteSizeSpan span = new AbsoluteSizeSpan(36);
spanString.setSpan(span, 0, 5, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
tv.append(spanString);
}

以上是比较常用的,还有其他例如设置字体加粗,下划线,删除线等,都可以实现

我们这里主要用到给字体设置背景色,通过正则表达式匹配关键字,设置段落中匹配到的关键字高亮

/***
* 指定关键字高亮 字符串整体高亮
* @param originString 原字符串
* @param keyWords 高亮字符串
* @param highLightColor 高亮色值
* @return 高亮后的字符串
*/
public static SpannableString getHighLightWord(String originString, String keyWords, int highLightColor) {
SpannableString originSpannableString = new SpannableString(originString);
if (!TextUtils.isEmpty(keyWords)) {
Pattern pattern = Pattern.compile(keyWords);
Matcher matcher = pattern.matcher(originSpannableString);
while (matcher.find()) {
int startIndex = matcher.start();
int endIndex = matcher.end();
originSpannableString.setSpan(new ForegroundColorSpan(highLightColor), startIndex, endIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
return originSpannableString;
}

在扩展一下,可以支持关键字,关键词拆分显示

类似:测试1234测1234试(测试为高亮字,实现测试/测/试分别高亮)

/***
* 指定关键字高亮 支持分段高亮
* @param originString
* @param keyWords
* @param highLightColor
* @return
*/
public static SpannableString getHighLightWords(String originString, String keyWords, int highLightColor) {
SpannableString originSpannableString = new SpannableString(originString);
if (!TextUtils.isEmpty(keyWords)) {
for (int i = 0; i < keyWords.length(); i++) {
Pattern p = Pattern.compile(String.valueOf(keyWords.charAt(i)));
Matcher m = p.matcher(originSpannableString);
while (m.find()) {
int start = m.start();
int end = m.end();
originSpannableString.setSpan(new ForegroundColorSpan(highLightColor), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
return originSpannableString;
}

字符可以,那么数组呢,是不是也可以实现了?

/***
* 指定关键字数组高亮
* @param originString 原字符串
* @param keyWords 高亮字符串数组
* @param highLightColor 高亮色值
* @return 高亮后的字符串
*/
public static SpannableString getHighLightWordsArray(String originString, String[] keyWords, int highLightColor) {
SpannableString originSpannableString = new SpannableString(originString);
if (keyWords != null && keyWords.length > 0) {
for (int i = 0; i < keyWords.length; i++) {
Pattern p = Pattern.compile(keyWords[i]);
Matcher m = p.matcher(originSpannableString);
while (m.find()) {
int start = m.start();
int end = m.end();
originSpannableString.setSpan(new ForegroundColorSpan(highLightColor), start, end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
}
return originSpannableString;
}

总结

这样不管来什么需求,是不是都可以满足了,随便产品经理提,要什么给什么

收起阅读 »

聊一聊Android开发利器之adb

学无止境,有一技旁身,至少不至于孤陋寡闻。adb的全称为Android Debug Bridge,通过adb可以方便我们调试Android程序。作为移动端开发相关的同学,掌握所需要的adb操作命令是非常必须的,就把平时工作中用得相对比较多的adb命令做个梳理。...
继续阅读 »

学无止境,有一技旁身,至少不至于孤陋寡闻。

adb的全称为Android Debug Bridge,通过adb可以方便我们调试Android程序。作为移动端开发相关的同学,掌握所需要的adb操作命令是非常必须的,就把平时工作中用得相对比较多的adb命令做个梳理。(日常中把adb操作命令搭配shell alias使用起来更方便)

ADB常用命令

1.启动/停止adb server命令

adb start-server  //启动命令
adb kill-server //停止命令

2. 通过adb查看设备相关信息

  1. 查询已连接设备/模拟器
    adb devices
  2. 查看手机型号
    adb shell getprop ro.product.model
  3. 查看电池状况
    adb shell dumpsys battery
  4. 查看屏幕分辨率
    adb shell wm size
  5. 查看屏幕密度
    adb shell wm density
  6. 查看显示屏参数
    adb shell dumpsys window displays
  7. 查看Android系统版本
    adb shell getprop ro.build.version.release
  8. 查看CPU信息
    adb shell cat /proc/cpuinfo
  9. 查看手机CPU架构
    adb shell getprop ro.product.cpu.abi
  10. 查看内存信息
    adb shell cat /proc/meminfo

3. 通过adb连接设备命令

adb [-d|-e|-s ]
如果只有一个设备/模拟器连接时,可以省略掉 [-d|-e|-s ] 这一部分,直接使用 adb即可 。 如果有多个设备/模拟器连接,则需要为命令指定目标设备。

参数含义
-d指定当前唯一通过 USB 连接的 Android 设备为命令目标
-e指定当前唯一运行的模拟器为命令目标
-s <serialNumber>指定相应 serialNumber 号的设备/模拟器为命令目标
在多个设备/模拟器连接的情况下较常用的是-s参数,serialNumber 可以通过adb devices命令获取。如:
$ adb devices
List of devices attached
cfxxxxxx device
emulator-5554 device
10.xxx.xxx.x:5555 device

输出里的 cfxxxxxxemulator-5554 和 10.xxx.xxx.x:5555 即为 serialNumber。 比如这时想指定 cfxxxxxx 这个设备来运行 adb 命令 获取屏幕分辨率:

adb -s cfxxxxxx shell wm size

安装应用:

adb -s cfxxxxxx install hello.apk

遇到多设备/模拟器的情况均使用这几个参数为命令指定目标设备。

4. 通过adb在设备上操作应用相关

  1. 安装 APK

    adb install [-rtsdg] <apk_path>

    参数:
    adb install 后面可以跟一些可选参数来控制安装 APK 的行为,可用参数及含义如下:

    参数含义
    -r允许覆盖安装
    -t允许安装 AndroidManifest.xml 里 application 指定 android:testOnly="true" 的应用
    -s将应用安装到 sdcard
    -d允许降级覆盖安装
    -g授予所有运行时权限
  2. 卸载应用

    adb uninstall [-k] <packagename>

    <packagename> 表示应用的包名,-k 参数可选,表示卸载应用但保留数据和缓存目录。

    adb uninstall com.vic.dynamicview
  3. 强制停止应用

    adb shell am force-stop <packagename>

    命令示例:

    adb shell am force-stop com.vic.dynamicview
  4. 调起对应的Activity

    adb shell am start [options] <INTENT>

    例如:

    adb shell am start -n com.vic.dynamicview/.MainActivity --es "params" "hello, world"

    表示调起 com.vic.dynamicview/.MainActivity 并传给它 string 数据键值对 params - hello, world。

  5. 查看前台 Activity

    adb shell dumpsys activity activities | grep ResumedActivity

    查看activity堆栈信息: adb shell dumpsys activity

    ACTIVITY MANAGER PENDING INTENTS (adb shell dumpsys activity intents)
    ...
    ACTIVITY MANAGER BROADCAST STATE (adb shell dumpsys activity broadcasts)
    ...
    ACTIVITY MANAGER CONTENT PROVIDERS (adb shell dumpsys activity providers)
    ...
    ACTIVITY MANAGER SERVICES (adb shell dumpsys activity services)
    ...
    ACTIVITY MANAGER ACTIVITIES (adb shell dumpsys activity activities)
    ...
    ACTIVITY MANAGER RUNNING PROCESSES (adb shell dumpsys activity processes)
    ...
  6. 打开系统设置:
    adb shell am start -n com.android.settings/com.android.settings.Settings

  7. 打开开发者选项:
    adb shell am start -a com.android.settings.APPLICATION_DEVELOPMENT_SETTINGS

  8. 进入WiFi设置
    adb shell am start -a android.settings.WIRELESS_SETTINGS

  9. 重启系统
    adb reboot

5. 通过adb操作日志相关

  1. logcathelp帮助信息
    adb logcat --help 可以查看logcat帮助信息
    adb logcat 命令格式: adb logcat [选项] [过滤项], 其中 选项 和 过滤项 在 中括号 [] 中, 说明这是可选的;

  2. 输出日志信息到文件:
    ">"输出 :
    ">" 后面跟着要输出的日志文件, 可以将 logcat 日志输出到文件中, 使用 adb logcat > log 命令, 使用 more log 命令查看日志信息;
    如:adb logcat > ~/logdebug.log

  3. 输出指定标签内容:
    "-s"选项 : 设置默认的过滤器, 如 我们想要输出 "System.out" 标签的信息, 就可以使用 adb logcat -s System.out 命令;

  4. 清空日志缓存信息:
    使用 adb logcat -c 命令, 可以将之前的日志信息清空, 重新开始输出日志信息;

  5. 输出缓存日志:
    使用 adb logcat -d 命令, 输出命令, 之后退出命令, 不会进行阻塞;

  6. 输出最近的日志:
    使用 adb logcat -t 5 命令, 可以输出最近的5行日志, 并且不会阻塞;

  7. 日志过滤:
    注意:在windows上不能使用grep关键字,可以用findstr代替grep.

    • 过滤固定字符串:
      adb logcat | grep logtag
      adb logcat | grep -i logtag #忽略大小写。
      adb logcat | grep logtag > ~/result.log #将过滤后的日志输出到文件
      adb logcat | grep --color=auto -i logtag #设置匹配字符串颜色。

    • 使用正则表达式匹配
      adb logcat | grep "^..Activity"

ADB其他命令

1. 清除应用数据与缓存

adb shell pm clear <packagename>

<packagename> 表示应用名包,这条命令的效果相当于在设置里的应用信息界面点击了「清除缓存」和「清除数据」。

adb shell pm clear com.xxx.xxx

2. 与应用交互操作

主要是使用 am <command> 命令,常用的 <command> 如下:

command用途
start [options] <INTENT>启动 <INTENT> 指定的 Activity
startservice [options] <INTENT>启动 <INTENT> 指定的 Service
broadcast [options] <INTENT>发送 <INTENT> 指定的广播
force-stop <packagename>停止 <packagename> 相关的进程

<INTENT> 参数很灵活,和写 Android 程序时代码里的 Intent 相对应。

用于决定 intent 对象的选项如下:

参数含义
-a <ACTION>指定 action,比如 android.intent.action.VIEW
-c <CATEGORY>指定 category,比如 android.intent.category.APP_CONTACTS
-n <COMPONENT>指定完整 component 名,用于明确指定启动哪个 Activity,如 com.example.app/.ExampleActivity

<INTENT> 里还能带数据,就像写代码时的 Bundle 一样:

参数含义
--esn <EXTRA_KEY>null 值(只有 key 名)
-e--es <EXTRA_KEY> <EXTRA_STRING_VALUE>`
--ez <EXTRA_KEY> <EXTRA_BOOLEAN_VALUE>boolean 值
--ei <EXTRA_KEY> <EXTRA_INT_VALUE>integer 值
--el <EXTRA_KEY> <EXTRA_LONG_VALUE>long 值
--ef <EXTRA_KEY> <EXTRA_FLOAT_VALUE>float 值
--eu <EXTRA_KEY> <EXTRA_URI_VALUE>URI
--ecn <EXTRA_KEY> <EXTRA_COMPONENT_NAME_VALUE>component name
--eia <EXTRA_KEY> <EXTRA_INT_VALUE>[,<EXTRA_INT_VALUE...]integer 数组
--ela <EXTRA_KEY> <EXTRA_LONG_VALUE>[,<EXTRA_LONG_VALUE...]long 数组
  1. 调起Activity

    adb shell am start [options] <INTENT>

    例如:

    adb shell am start -n com.cc.test/.MainActivity --es "params" "hello, world"

    表示调起 com.cc.test/.MainActivity 并传给它 string 数据键值对 params - hello, world。

  2. 调起Service

    adb shell am startservice [options] <INTENT>

    例如:

    adb shell am startservice -n com.tencent.mm/.plugin.accountsync.model.AccountAuthenticatorService
  3. 发送广播

    adb shell am broadcast [options] <INTENT>

    可以向所有组件广播,也可以只向指定组件广播。 例如,向所有组件广播 BOOT_COMPLETED:

    adb shell am broadcast -a android.intent.action.BOOT_COMPLETED

    又例如,只向 com.cc.test/.BootCompletedReceiver 广播 BOOT_COMPLETED:

    adb shell am broadcast -a android.intent.action.BOOT_COMPLETED -n com.cc.test/.BootCompletedReceiver
  4. 撤销应用程序的权限

    1. 向应用授予权限。只能授予应用程序声明的可选权限
    adb shell pm grant <packagename> <PACKAGE_PERMISSION>

    例如:adb -d shell pm grant packageName android.permission.BATTERY_STATS

    1. 取消应用授权
    adb shell pm revoke <packagename> <PACKAGE_PERMISSION>

3. 模拟按键/输入

Usage: input [<source>] <command> [<arg>...]

The sources are:
mouse
keyboard
joystick
touchnavigation
touchpad
trackball
stylus
dpad
gesture
touchscreen
gamepad

The commands and default sources are:
text <string> (Default: touchscreen)
keyevent [--longpress] <key code number or name> ... (Default: keyboard)
tap <x> <y> (Default: touchscreen)
swipe <x1> <y1> <x2> <y2> [duration(ms)] (Default: touchscreen)
press (Default: trackball)
roll <dx> <dy> (Default: trackball)

比如模拟点击://在屏幕上点击坐标点x=50 y=250的位置。

adb shell input tap 50 250

结合shell alias使用adb

shell终端的别名只是命令的简写,有类似键盘快捷键的效果。如果你经常执行某个长长的命令,可以给它起一个简短的化名。使用alias命令列出所有定义的别名。
你可以在~/.bashrc(.zshrc)文件中直接定义别名如alias logRunActivity="adb shell dumpsys activity activities | grep 'Run*'",也可以新创建一个文件如.byterc, 然后在当前shell对应的文件中.bashrc或者.zshrc 中增加source ~/.byterc,重新source配置,使得配置生效,即可使别名全局生效。使用别名可以节省时间、提高工作效率。

如何添加别名alias

下面在MAC环境采用新建文件形式添加别名,步骤如下:

  1. 新建.byterc 文件
    • 如果已经新建,直接打开
      open ~/.byterc
    • 没有新建,则新建后打开
      新建: touch ~/.byterc
      打开:open ~/.byterc
  2. 在.zshrc中添加source ~/.byterc
  3. 在打开的.byterc文件中定义别名
    alias logRunActivity="adb shell dumpsys activity activities | grep 'Run*'"
    Android同学应该知道作用就是查看当前设备运行的Activity信息
  4. 重新source配置,使得配置生效
    $ source ~/.byterc
    如果不是新建文件,直接使用.bashrc或者.zshrc ,直接source对应的配置即可,如:$ source ~/.zshrc .
  5. 此时在命令行中直接执行logRunActivity 即可查看当前设备运行的Activity信息。

注意: 可使用$ alias查看当前有设置哪些别名操作。


收起阅读 »

Swift 中的 Self & Self.Type & self

iOS
Swift 中的 Self & Self.Type & self这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战你可能在写代码的时候已经用过很多次 self 这个关键词了,但是你有没有想过什么是 self 呢?今天我们...
继续阅读 »

Swift 中的 Self & Self.Type & self

这是我参与11月更文挑战的第13天,活动详情查看:2021最后一次更文挑战


你可能在写代码的时候已经用过很多次 self 这个关键词了,但是你有没有想过什么是 self 呢?今天我们就来看看:

  • 什么是 self、Self 和 Self.Type?
  • 都在什么情况下使用?

self

这个大家用的比较多了,self 通常用于当你需要引用你当前所在范围内的对象时。所以,例如,如果在 Rocket 的实例方法中使用 self,在这种情况下,self 将是该 Rocket 的实例。这个很好理解~

struct Rocket {
    func launch() {
        print("10 秒内发射 \(self)")
    }
}

let rocket = Rocket()
rocket.launch() //10 秒内发射 Rocket()

但是,如果要在类方法或静态方法中使用 self,该怎么办?在这种情况下,self 不能作为对实例的引用,因为没有实例,而 self 具有当前类型的值。这是因为静态方法和类方法存在于类型本身而不是实例上。

class Dog {
    class func bark() {
        print("\(self) 汪汪汪!")
    }
}

Dog.bark() //Dog 汪汪汪!


struct Cat {
    static func meow() {
        print("\(self) 喵喵喵!")
    }
}

Cat.meow() // Cat 喵喵喵!


元类型

还有个需要注意的地方。所有的值都应该有一个类型,包括 self。就像上面提到的,静态和类方法存在于类型上,所以在这种情况下,self 就拥有了一种类型:Self.Type。比如:Dog.Type 就保存所有 Dog 的类型值。

包含其他类型的类型称为元类型

有点绕哈,简单来说,元类型 Dog.Type 不仅可以保存 Dog 类型的值,还可以保存它的所有子类的值。比如下面这个例子,其中 Labrador 是 Dog 的一个子类。

class Dog {
    class func bark() {
        print("\(self) 汪汪汪!")
    }
}

class Labrador: Dog {

}

Labrador.bark() //Labrador 汪汪汪!

如果你想将 type 本身当做一个属性,或者将其传递到函数中,那么你也可以将 type 本身作为值使用。这时候,就可以这样用:Type.self。

let dogType: Dog.Type = Labrador.self

func saySomething(dog: Dog.Type) {
    print("\(dog) 汪汪汪!")
}

saySomething(dog: dogType) // Labrador 汪汪汪!


Self

最后,就是大写 s 开头的 Self。在创建工厂方法或从协议方法返回具体类型时,非常的有用:

struct Rocket {
    func launch() {
        print("10 秒内发射 \(self)")
    }
}

extension Rocket {
    static func makeRocket() -> Self {
        return Rocket()
    }
}

protocol Factory {
    func make() -> Self
}

extension Rocket: Factory {
    func make() -> Rocket {
        return Rocket()
    }
}

收起阅读 »

iOS小技能:快速创建OEM项目app

iOS
iOS小技能:快速创建OEM项目app这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战。引言贴牌生产(英语:Original Equipment Manufacturer, OEM)因采购方可提供品牌和授权,允许制造方生产贴有该品牌的...
继续阅读 »

iOS小技能:快速创建OEM项目app

这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战

引言

贴牌生产(英语:Original Equipment Manufacturer, OEM)

因采购方可提供品牌和授权,允许制造方生产贴有该品牌的产品,所以俗称“贴牌生产”。

需求背景: SAAS平台级应用系统为一个特大商户,提供专属OEM项目,在原有通用app的基础上进行定制化开发

例如去掉开屏广告,删除部分模块,保留核心模块。更换专属app icon以及主题色

I 上架资料

  1. 用户协议及隐私政策
  2. App版本、 审核测试账号信息
  3. icon、名称、套装 ID(bundle identifier)
  4. 关键词:
  5. app描述:
  6. 技术支持网址使用:

kunnan.blog.csdn.net/article/det…

II 开发小细节

  1. 更换基础配置信息,比如消息推送证书、第三方SDK的ApiKey、启动图、用户协议及隐私政策。
  2. 接口修改:比如登录接口新增SysId请求字段用于区分新旧版、修改域名(备案信息)
  3. 废弃开屏广告pod 'GDTMobSDK' ,'4.13.26'

1.1 更换高德定位SDK的apiKey

    NSString *AMapKey = @"";
[AMapServices sharedServices].apiKey = AMapKey;


1.2 更新消息推送证书和极光的appKey

  1. Mac 上的“钥匙串访问”创建证书签名请求 (CSR)

a. 启动位于 /Applications/Utilities 中的“钥匙串访问”。

b. 选取“钥匙串访问”>“证书助理”>“从证书颁发机构请求证书”。

c. 在“证书助理”对话框中,在“用户电子邮件地址”栏位中输入电子邮件地址。

d. 在“常用名称”栏位中,输入密钥的名称 (例如,Gita Kumar Dev Key)。

e. 将“CA 电子邮件地址”栏位留空。

f. 选取“存储到磁盘”,然后点按“继续”。

help.apple.com/developer-a…

在这里插入图片描述

  1. 从developer.apple.com 后台找到对应的Identifiers创建消息推送证书,并双击aps.cer安装到本地Mac,然后从钥匙串导出P12的正式上传到极光后台。

docs.jiguang.cn//jpush/clie…在这里插入图片描述

  1. 更换appKey(极光平台应用的唯一标识)
        [JPUSHService setupWithOption:launchOptions appKey:@""
channel:@"App Store"
apsForProduction:YES
advertisingIdentifier:nil];


http://www.jiguang.cn/accounts/lo…

1.3 更换Bugly的APPId

    [Bugly startWithAppId:@""];//异常上报


1.4 app启动的新版本提示

更换appid

    [self checkTheVersionWithappid:@""];


检查版本

在这里插入图片描述


- (void)checkTheVersionWithappid:(NSString*)appid{


[QCTNetworkHelper getWithUrl:[NSString stringWithFormat:@"http://itunes.apple.com/cn/lookup?id=%@",appid] params:nil successBlock:^(NSDictionary *result) {
if ([[result objectForKey:@"results"] isKindOfClass:[NSArray class]]) {
NSArray *tempArr = [result objectForKey:@"results"];
if (tempArr.count) {


NSString *versionStr =[[tempArr objectAtIndex:0] valueForKey:@"version"];
NSString *appStoreVersion = [versionStr stringByReplacingOccurrencesOfString:@"." withString:@""] ;
if (appStoreVersion.length==2) {
appStoreVersion = [appStoreVersion stringByAppendingString:@"0"];
}else if (appStoreVersion.length==1){
appStoreVersion = [appStoreVersion stringByAppendingString:@"00"];
}

NSDictionary *infoDic=[[NSBundle mainBundle] infoDictionary];
NSString* currentVersion = [[infoDic valueForKey:@"CFBundleShortVersionString"] stringByReplacingOccurrencesOfString:@"." withString:@""];

currentVersion = [currentVersion stringByReplacingOccurrencesOfString:@"." withString:@""];
if (currentVersion.length==2) {
currentVersion = [currentVersion stringByAppendingString:@"0"];
}else if (currentVersion.length==1){
currentVersion = [currentVersion stringByAppendingString:@"00"];
}



NSLog(@"currentVersion: %@",currentVersion);


if([self compareVesionWithServerVersion:versionStr]){



UIAlertController *alertController = [UIAlertController alertControllerWithTitle:[NSString stringWithFormat:@"%@%@",QCTLocal(@"Discover_a_new_version"),versionStr] message:QCTLocal(@"Whethertoupdate") preferredStyle:UIAlertControllerStyleAlert];
// "Illtalkaboutitlater"= "稍后再说";
// "Update now" = "立即去更新";
// "Unupdate"= "取消更新";

[alertController addAction:[UIAlertAction actionWithTitle:QCTLocal(@"Illtalkaboutitlater") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
NSLog(@"取消更新");
}]];
[alertController addAction:[UIAlertAction actionWithTitle:QCTLocal(@"Updatenow") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"itms-apps://itunes.apple.com/app/id%@",appid]];
if (@available(iOS 10.0, *)) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
}];
} else {
// Fallback on earlier vesions
[[UIApplication sharedApplication] openURL:url];
}
}]];
[[QCT_Common getCurrentVC] presentViewController:alertController animated:YES completion:nil];
}
}
}
} failureBlock:^(NSError *error) {
NSLog(@"检查版本错误: %@",error);
}];
}


see also

更多内容请关注 #小程序:iOS逆向,只为你呈现有价值的信息,专注于移动端技术研究领域;更多服务和咨询请关注#公众号:iOS逆向

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

收起阅读 »

objc_msgsend(中)方法动态决议

iOS
引入在学习本文之前我们应该了解objc_msgsend消息快速查找(上) objc_msgsend(中)消息慢速查找 当快速消息查找和消息慢速查找都也找不到imp时,苹果系统后续是怎么处理的我们一起来学习! 方法动态决议主要做了哪些事情?准...
继续阅读 »


引入

在学习本文之前我们应该了解

当快速消息查找和消息慢速查找都也找不到imp时,苹果系统后续是怎么处理的我们一起来学习! 方法动态决议主要做了哪些事情?

准备工作

resolveMethod_locked动态方法决议

1.png

  • 赋值imp = forward_imp

  • 做了个单例判断动态控制执行流程根据behavior方法只执行一次。

2.png

对象方法的动态决议

3.png

类方法的动态决议

3.png

lookUpImpOrForwardTryCache

4.png

cache_getImp

5.png

  • 苹果给与一次动态方法决议的机会来挽救APP
  • 如果是类请用resolveInstanceMethod
  • 如果是元类请用resolveClassMethod

如果都没有处理那么imp = forward_imp ,const IMP forward_imp = (IMP)_objc_msgForward_impcache;

_objc_msgForward_impcache探究

6.png

  • __objc_forward_handler主要看这个函数处理

__objc_forward_handler

7.png

代码案例分析

   int main(int argc, const char * argv[]) {
    @autoreleasepool {
        LGTeacher *p = [LGTeacher alloc];
        [t sayHappy];
[LGTeacher saygood];
    }
    return 0;
}


崩溃信息

2021-11-28 22:36:39.223567+0800 KCObjcBuild[12626:762145] +[LGTeacher sayHappy]: unrecognized selector sent to class 0x100008310

2021-11-28 22:36:39.226012+0800 KCObjcBuild[12626:762145] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '+[LGTeacher sayHappy]: unrecognized selector sent to class 0x100008310'

复制代码

动态方法决议处理对象方法找不到

代码动态决议处理imp修复崩溃

@implementation LGTeacher

-(void)text{
    NSLog(@"%s", __func__ );
}

+(void)say777{

    NSLog(@"%s", __func__ );
}

// 对象方法动态决议

+(BOOL**)resolveInstanceMethod:(SEL)sel{

    if (sel == @selector(sayHappy)) {

        IMP imp =class_getMethodImplementation(self, @selector(text));
        Method m = class_getInstanceMethod(self, @selector(text));
        const char * type = method_getTypeEncoding(m);
        return** class_addMethod(self, sel, imp, type);
    }
    return [super resolveInstanceMethod:sel];

}

//类方法动态决议

+ (BOOL)resolveClassMethod:(SEL)sel{
    if (sel == @selector(saygood)) {
        IMP  imp7 = class_getMethodImplementation(objc_getMetaClass("LGTeacher"), @selector(say777));
        Method m  = class_getInstanceMethod(objc_getMetaClass("LGTeacher"), @selector(say777));
        const char type = method_getTypeEncoding(m);
        return class_addMethod(objc_getMetaClass("LGTeacher"), sel, imp7, type);
    }

    return [super resolveClassMethod:sel];

}

@end


运行打印信息

2021-11-29 16:30:46.403671+0800 KCObjcBuild[27071:213498] -[LGTeacher text]

2021-11-29 16:30:46.404186+0800 KCObjcBuild[27071:213498] +[LGTeacher say777]

  • 找不到imp我们动态添加一个imp ,但这样处理太麻烦了。
  • 实例方法方法查找流程 类->父类->NSObject->nil
  • 类方法查找流程 元类->父类->根元类-NsObject->nil

最终都会找到NSobject.我们可以在NSObject统一处理 所以我们可以给NSObject创建个分类

@implementation NSObject (Xu)
+(BOOL)resolveInstanceMethod:(SEL)sel{
if (@selector(sayHello) == sel) {
NSLog(@"--进入%@--",NSStringFromSelector(sel));
IMP imp = class_getMethodImplementation(self , @selector(sayHello2));
Method meth = class_getInstanceMethod(self , @selector(sayHello2));
const char * type = method_getTypeEncoding(meth);
return class_addMethod(self ,sel, imp, type);;

}else if (@selector(test) == sel){
NSLog(@"--进入%@--",NSStringFromSelector(sel));
IMP imp = class_getMethodImplementation(object_getClass([self class]), @selector(newTest));
Method meth = class_getClassMethod(object_getClass([self class]) , @selector(newTest));
const char * type = method_getTypeEncoding(meth);
return class_addMethod(object_getClass([self class]) ,sel, imp, type);;
}
return NO;
}

- (void)sayHello2{
NSLog(@"--%s---",__func__);
}

+(void)newTest{
NSLog(@"--%s---",__func__);
}

@end


实例方法是类方法调用,系统都自动调用了resolveInstanceMethod方法,和上面探究的吻合。 动态方法决议优点

  • 可以统一处理方法崩溃的问题,出现方法崩溃可以上报服务器,或者跳转到首页
  • 如果项目中是不同的模块你可以根据命名不同,进行业务的区别
  • 这种方式叫切面编程熟成AOP

方法动态决议流程图

9.png

问题

  • resolveInstanceMethod为什么调用两次?
  • 统一处理方案怎么处理判断问题,可能是对象方法崩溃也可能是类方法崩溃,怎么处理?
  • 动态方法决议后苹果后续就没有处理了吗?

链接:https://juejin.cn/post/7035965819955707935
收起阅读 »

Android静态代码扫描效率优化与实践(下)

前面分析了如何获取差异文件以及增量扫描的原理,分析的重点还是侧重在Lint工具本身的实现机制上。接下来分析,在Gradle中如何实现一个增量扫描任务。大家知道,通过执行./gradlew lint命令来执行Lint静态代码检测任务。创建一个新的Android工...
继续阅读 »


Android静态代码扫描效率优化与实践(下)
Lint增量扫描Gradle任务实现

前面分析了如何获取差异文件以及增量扫描的原理,分析的重点还是侧重在Lint工具本身的实现机制上。接下来分析,在Gradle中如何实现一个增量扫描任务。大家知道,通过执行./gradlew lint命令来执行Lint静态代码检测任务。创建一个新的Android工程,在Gradle任务列表中可以在Verification这个组下面找到几个Lint任务,如下所示:

Android静态代码扫描效率优化与实践_美团_12

这几个任务就是 Android Gradle插件在加载的时候默认创建的。分别对应于以下几个Task:

  • lint->LintGlobalTask:由TaskManager创建;

  • lintDebug、lintRelease、lintVitalRelease->LintPerVariantTask:由ApplicationTaskManager或者LibraryTaskManager创建,其中lintVitalRelease只在release下生成。

所以,在Android Gradle 插件中,应用于Lint的任务分别为LintGlobalTask和LintPerVariantTask。他们的区别是前者执行的是扫描所有Variant,后者执行只针对单独的Variant。而我们的增量扫描任务其实是跟Variant无关的,因为我们会把所有差异文件都收集到。无论是LintGlobalTask或者是LintPerVariantTask,都继承自LintBaseTask。最终的扫描任务在LintGradleExecution的runLint方法中执行,这个类位于lint-gradle-26.1.1中,前面提到这个库是基于Lint的API针对Gradle任务做的一些封装。

/** Runs lint on the given variant and returns the set of warnings */
  private Pair, LintBaseline> runLint(
          @Nullable Variant variant,
          @NonNull VariantInputs variantInputs,
          boolean report, boolean isAndroid) {
      IssueRegistry registry = createIssueRegistry(isAndroid);
      LintCliFlags flags = new LintCliFlags();
      LintGradleClient client =
              new LintGradleClient(
                      descriptor.getGradlePluginVersion(),
                      registry,
                      flags,
                      descriptor.getProject(),
                      descriptor.getSdkHome(),
                      variant,
                      variantInputs,
                      descriptor.getBuildTools(),
                      isAndroid);
      boolean fatalOnly = descriptor.isFatalOnly();
      if (fatalOnly) {
          flags.setFatalOnly(true);
      }
      LintOptions lintOptions = descriptor.getLintOptions();
      if (lintOptions != null) {
          syncOptions(
                  lintOptions,
                  client,
                  flags,
                  variant,
                  descriptor.getProject(),
                  descriptor.getReportsDir(),
                  report,
                  fatalOnly);
      } else {
          // Set up some default reporters
          flags.getReporters().add(Reporter.createTextReporter(client, flags, null,
                  new PrintWriter(System.out, true), false));
          File html = validateOutputFile(createOutputPath(descriptor.getProject(), null, ".html",
                  null, flags.isFatalOnly()));
          File xml = validateOutputFile(createOutputPath(descriptor.getProject(), null, DOT_XML,
                  null, flags.isFatalOnly()));
          try {
              flags.getReporters().add(Reporter.createHtmlReporter(client, html, flags));
              flags.getReporters().add(Reporter.createXmlReporter(client, xml, false));
          } catch (IOException e) {
              throw new GradleException(e.getMessage(), e);
          }
      }
      if (!report || fatalOnly) {
          flags.setQuiet(true);
      }
      flags.setWriteBaselineIfMissing(report && !fatalOnly);

      Pair, LintBaseline> warnings;
      try {
          warnings = client.run(registry);
      } catch (IOException e) {
          throw new GradleException("Invalid arguments.", e);
      }

      if (report && client.haveErrors() && flags.isSetExitCode()) {
          abort(client, warnings.getFirst(), isAndroid);
      }

      return warnings;
  }

我们在这个方法中看到了warnings = client.run(registry),这就是Lint扫描得到的结果集。总结一下这个方法中做了哪些准备工作用于Lint扫描:

  1. 创建IssueRegistry,包含了Lint内建的BuiltinIssueRegistry;

  2. 创建LintCliFlags;

  3. 创建LintGradleClient,这里面传入了一大堆参数,都是从Gradle Android 插件的运行环境中获得;

  4. 同步LintOptions,这一步是将我们在build.gralde中配置的一些Lint相关的DSL属性,同步设置给LintCliFlags,给真正的Lint 扫描核心库使用;

  5. 执行Client的Run方法,开始扫描。

扫描的过程上面的原理部分已经分析了,现在我们思考一下如何构造增量扫描的任务。我们已经分析到扫描的关键点是client.run(registry),所以我们需要构造一个Client来执行扫描。一个想法是通过反射来获取Client的各个参数,当然这个思路是可行的,我们也验证过实现了一个用反射方式构造的Client。但是反射这种方式有个问题是丢失了从Gradle任务执行到调用Lint API开始扫描这一过程中做的其他事情,侵入性比较高,所以我们最终采用继承LintBaseTask自行实现增量扫描任务的方式。

FindBugs扫描简介

FindBugs是一个静态分析工具,它检查类或者JAR 文件,通过Apache的 BCEL 库来分析Class,将字节码与一组缺陷模式进行对比以发现问题。FindBugs自身定义了一套缺陷模式,目前的版本3.0.1内置了总计300多种缺陷,详细可参考 官方文档 。FindBugs作为一个扫描的工具集,可以非常灵活的集成在各种编译工具中。接下来,我们主要分析在Gradle中FindBugs的相关内容。

Gradle FindBugs任务属性分析

在Gradle的内置任务中,有一个FindBugs的Task,我们看一下 官方文档 对Gradle属性的描述。

选几个比较重要的属性介绍:

  • Classes

该属性表示我们要分析的Class文件集合,通常我们会把编译结果的Class目录用于扫描。
  • Classpath

分析目标集合中的Class需要用到的所有相关的Classes路径,但是并不会分析它们自身,只用于扫描。
  • Effort

包含MIN,Default,MAX,级别越高,分析得越严谨越耗时。
  • findbugsClasspath

Finbugs库相关的依赖路径,用于配置扫描的引擎库。
  • reportLevel

报告级别,分为Low,Medium,High。如果为Low,所有Bug都报告,如果为High,仅报告High优先级。
  • Reports

扫描结果存放路径。

通过以上属性解释,不难发现要FindBugs增量扫描,只需要指定Classes的文件集合就可以了。

FindBugs任务增量扫描分析

在做增量扫描任务之前,我们先来看一下FindBugs IDEA插件是如何进行单个文件扫描的。

Android静态代码扫描效率优化与实践_Android教程_13

我们选择Analyze Current File对当前文件进行扫描,扫描结果如下所示:

Android静态代码扫描效率优化与实践_Android教程_14

可以看到确实只扫描了一个文件。那么扫描到底使用了哪些输入数据呢,我们可以通过扫描结果的提示清楚看到:

Android静态代码扫描效率优化与实践_美团_15

这里我们能看到很多有用的信息:

  • 源码目录列表,包含了工程中的Java目录,res目录,以及编译过程中生成的一些类目录;

  • 需要分析的目标Class集合,为编译后的Build目录下的当前Java文件对应的Class文件;

  • Aux Classpath Entries,表示分析上面的目标文件需要用到的类路径。

所以,根据IDEA的扫描结果来看,我们在做增量扫描的时候需要解决上面这几个属性的获取。在前面我们分析的属性是Gradle在FindBugs lib的基础上,定义的一套对应的Task属性。真正的Finbugs属性我们可以通过 官方文档 或者源码中查到。

配置AuxClasspath

前文提到,ClassPath是用来分析目标文件需要用到的相关依赖Class,但本身并不会被分析,所以我们需要尽可能全的找到所有的依赖库,否则在扫描的时候会报依赖的类库找不到。

FileCollection buildClasses = project.fileTree(dir: "${project.buildDir}/intermediates/classes/${variant.flavorName}/${variant.buildType.name}",includes: classIncludes)

FileCollection targetClasspath = project.files()
GradleUtils.collectDepProject(project, variant).each { targetProject ->
  GradleUtils.getAndroidVariants(targetProject).each { targetVariant ->
      if (targetVariant.name.capitalize().equalsIgnoreCase(variant.name.capitalize())) {
          targetClasspath += targetVariant.javaCompile.classpath
      }
  }
}

classpath = variant.javaCompile.classpath + targetClasspath + buildClasses
FindBugs增量扫描误报优化

对于增量文件扫描,参与的少数文件扫描在某些模式规则上可能会出现误判,但是全量扫描不会有问题,因为参与分析的目标文件是全集。举一个例子:

class A {
public static String buildTime = "";
....
}

静态变量buildTime会被认为应该加上Final,但是其实其他类会对这个变量赋值。如果单独扫描类A文件,就会报缺陷BUG_TYPE_MS_SHOULD_BE_FINAL。我们通过FindBugs-IDEA插件来扫描验证,也同样会有一样的问题。要解决此类问题,需要找到谁依赖了类A,并且一同参与扫描,同时也需要找出类A依赖了哪些文件,简单来说:需要找出与类A有直接关联的类。为了解决这个问题,我们通过ASM来找出相关的依赖,具体如下:

void findAllScanClasses(ConfigurableFileTree allClass) {
  allScanFiles = [] as HashSet
  String buildClassDir = "${project.buildDir}/$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN"

  Set moduleClassFiles = allClass.files
  for (File file : moduleClassFiles) {
      String[] splitPath = file.absolutePath.split("$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN/")
      if (splitPath.length > 1) {
          String className = getFileNameNoFlag(splitPath[1],'.')
          String innerClassPrefix = ""
          if (className.contains('$')) {
              innerClassPrefix = className.split('\\$')[0]
          }
          if (diffClassNamePath.contains(className) || diffClassNamePath.contains(innerClassPrefix)) {
              allScanFiles.add(file)
          } else {
              Iterable classToResolve = new ArrayList()
              classToResolve.add(file.absolutePath)
              Set dependencyClasses = Dependencies.findClassDependencies(project, new ClassAcceptor(), buildClassDir, classToResolve)
              for (File dependencyClass : dependencyClasses) {
                  if (diffClassNamePath.contains(getPackagePathName(dependencyClass))) {
                      allScanFiles.add(file)
                      break
                  }
              }
          }
      }
  }
}

通过以上方式,我们可以解决一些增量扫描时出现的误报情况,相比IDEA工具,我们更进一步降低了扫描部分文件的误报率。

CheckStyle增量扫描

相比而言,CheckStyle的增量扫描就比较简单了。CheckStyle对源码扫描,根据[ 官方文档]各个属性的描述,我们发现只要指定Source属性的值就可以指定扫描的目标文件。

void configureIncrementScanSource() {
  boolean isCheckPR = false
  DiffFileFinder diffFileFinder

  if (project.hasProperty(CodeDetectorExtension.CHECK_PR)) {
      isCheckPR = project.getProperties().get(CodeDetectorExtension.CHECK_PR)
  }

  if (isCheckPR) {
      diffFileFinder = new DiffFileFinderHelper.PRDiffFileFinder()
  } else {
      diffFileFinder = new DiffFileFinderHelper.LocalDiffFileFinder()
  }

  source diffFileFinder.findDiffFiles(project)

  if (getSource().isEmpty()) {
      println '没有找到差异java文件,跳过checkStyle检测'
  }
}

优化结果数据

经过全量扫描和增量扫描的优化,我们整个扫描效率得到了很大提升,一次PR构建扫描效率整体提升50%+。优化数据如下:

Android静态代码扫描效率优化与实践_Android教程_16

落地与沉淀

扫描工具通用性

解决了扫描效率问题,我们想怎么让更多的工程能低成本的使用这个扫描插件。对于一个已经存在的工程,如果没有使用过静态代码扫描,我们希望在接入扫描插件后续新增的代码能够保证其经过增量扫描没有问题。而老的存量代码,由于代码量过大增量扫描并没有效率上的优势,我们希望可以使用全量扫描逐步解决存量代码存在的问题。同时,为了配置工具的灵活,也提供配置来让接入方自己决定选择接入哪些工具。这样可以让扫描工具同时覆盖到新老项目,保证其通用。所以,要同时支持配置使用增量或者全量扫描任务,并且提供灵活的选择接入哪些扫描工具

扫描完整性保证

前面提到过,在FindBugs增量扫描可能会出现因为参与分析的目标文件集不全导致的某类匹配规则误报,所以在保证扫描效率的同时,也要保证扫描的完整性和准确性。我们的策略是以增量扫描为主,全量扫描为辅,PR提交使用增量扫描提高效率,在CI配置Daily Build使用全量扫描保证扫描完整和不遗漏

我们在自己的项目中实践配置如下:

apply plugin: 'code-detector'

codeDetector {
  // 配置静态代码检测报告的存放位置
  reportRelativePath = rootProject.file('reports')

  /**
    * 远程仓库地址,用于配置提交pr时增量检测
    */
  upstreamGitUrl = "ssh://git@xxxxxxxx.git"

  checkStyleConfig {
      /**
        * 开启或关闭 CheckStyle 检测
        * 开启:true
        * 关闭:false
        */
      enable = true
      /**
        * 出错后是否要终止检查
        * 终止:false
        * 不终止:true。配置成不终止的话 CheckStyleTask 不会失败,也不会拷贝错误报告
        */
      ignoreFailures = false
      /**
        * 是否在日志中展示违规信息
        * 显示:true
        * 不显示:false
        */
      showViolations = true
      /**
        * 统一配置自定义的 checkstyle.xml 和 checkstyle.xsl 的 uri
        * 配置路径为:
        *     "${checkStyleUri}/checkstyle.xml"
        *     "${checkStyleUri}/checkstyle.xsl"
        *
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      checkStyleUri = rootProject.file('codequality/checkstyle')
  }

  findBugsConfig {
      /**
        * 开启或关闭 Findbugs 检测
        * 开启:true
        * 关闭:false
        */
      enable = true
      /**
        * 可选项,设置分析工作的等级,默认值为 max
        * min, default, or max. max 分析更严谨,报告的 bug 更多. min 略微少些
        */
      effort = "max"
      /**
        * 可选项,默认值为 high
        * low, medium, high. 如果是 low 的话,那么报告所有的 bug
        */
      reportLevel = "high"
      /**
        * 统一配置自定义的 findbugs_include.xml 和 findbugs_exclude.xml 的 uri
        * 配置路径为:
        *     "${findBugsUri}/findbugs_include.xml"
        *     "${findBugsUri}/findbugs_exclude.xml"
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      findBugsUri = rootProject.file('codequality/findbugs')
  }

  lintConfig {

      /**
        * 开启或关闭 lint 检测
        * 开启:true
        * 关闭:false
        */
      enable = true

      /**
        * 统一配置自定义的 lint.xml 和 retrolambda_lint.xml 的 uri
        * 配置路径为:
        *     "${lintConfigUri}/lint.xml"
        *     "${lintConfigUri}/retrolambda_lint.xml"
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      lintConfigUri = rootProject.file('codequality/lint')
  }
}

我们希望扫描插件可以灵活指定增量扫描还是全量扫描以应对不同的使用场景,比如已存在项目的接入、新项目的接入、打包时的检测等。

执行脚本示例:

./gradlew ":${appModuleName}:assemble${ultimateVariantName}" -PdetectorEnable=true -PcheckStyleIncrement=true -PlintIncrement=true -PfindBugsIncrement=true -PcheckPR=${checkPR} -PsourceCommitHash=${sourceCommitHash} -PtargetBranch=${targetBranch} --stacktrace

希望一次任务可以暴露所有扫描工具发现的问题,当某一个工具扫描到问题后不终止任务,如果是本地运行在发现问题后可以自动打开浏览器方便查看问题原因。

def finalizedTaskArray = [lintTask,checkStyleTask,findbugsTask]
checkCodeTask.finalizedBy finalizedTaskArray

"open ${reportPath}".execute()

为了保证提交的PR不会引起打包问题影响包的交付,在PR时触发的任务实际为打包任务,我们将静态代码扫描任务挂接在打包任务中。由于我们的项目是多Flavor构建,在CI上我们将触发多个Job同时执行对应Flavor的增量扫描和打包任务。同时为了保证代码扫描的完整性,我们在真正的打包Job上执行全量扫描。

总结与展望

本文主要介绍了在静态代码扫描优化方面的一些思路与实践,并重点探讨了对Lint、FindBugs、CheckStyle增量扫描的一些尝试。通过对扫描插件的优化,我们在代码扫描的效率上得到了提升,同时在实践过程中我们也积累了自定义Lint检测规则的方案,未来我们将配合基础设施标准化建设,结合静态扫描插件制定一些标准化检测规则来更好的保证我们的代码规范以及质量。

参考资料

作者简介

鸿耀,美团餐饮生态技术团队研发工程师。

作者:美团技术团队 · 鸿耀
来源:https://blog.51cto.com/u_15197658/2768467

收起阅读 »

Android静态代码扫描效率优化与实践(上)

背景与问题思考与策略思考一:现有插件包含的扫描工具是否都是必需的?为了验证扫描工具的必要性,我们关心以下一些维度:经过以上的对比分析我们发现,工具的诞生都能针对性解决某一领域问题。CheckStyle的扫描速度快效率高,对代码风格和圈复杂度支持友好;FindB...
继续阅读 »



小伙伴们,美美又来推荐干货文章啦~本文为美团研发同学实战经验,主要介绍Android静态扫描工具Lint、CheckStyle、FindBugs在扫描效率优化上的一些探索和实践,希望大家喜欢鸭。

背景与问题

DevOps实践中,我们在CI(Continuous Integration)持续集成过程主要包含了代码提交、静态检测、单元测试、编译打包环节。其中静态代码检测可以在编码规范,代码缺陷,性能等问题上提前预知,从而保证项目的交付质量。Android项目常用的静态扫描工具包括CheckStyle、Lint、FindBugs等,为降低接入成本,美团内部孵化了静态代码扫描插件,集合了以上常用的扫描工具。项目初期引入集团内部基建时我们接入了代码扫描插件,在PR(Pull Request)流程中借助Jenkins插件来触发自动化构建,从而达到监控代码质量的目的。初期单次构建耗时平均在1~2min左右,对研发效率影响甚少。但是随着时间推移,代码量随业务倍增,项目也开始使用Flavor来满足复杂的需求,这使得我们的单次PR构建达到了8~9min左右,其中静态代码扫描的时长约占50%,持续集成效率不高,对我们的研发效率带来了挑战。

思考与策略

针对以上的背景和问题,我们思考以下几个问题:

思考一:现有插件包含的扫描工具是否都是必需的?

扫描工具对比

为了验证扫描工具的必要性,我们关心以下一些维度:

  • 扫码侧重点,对比各个工具分别能针对解决什么类型的问题;

  • 内置规则种类,列举各个工具提供的能力覆盖范围;

  • 扫描对象,对比各个工具针对什么样的文件类型扫描;

  • 原理简介,简单介绍各个工具的扫描原理;

  • 优缺点,简单对比各个工具扫描效率、扩展性、定制性、全面性上的表现。

Android静态代码扫描效率优化与实践_美团_03

注:FindBugs只支持Java1.0~1.8,已经被SpotBugs替代。鉴于部分老项目并没有迁移到Java8,目前我们并没有使用SpotBugs代替FindBugs的原因如下,详情参考 官方文档
Android静态代码扫描效率优化与实践_美团_04
同时,SpotBugs的作者也在 讨论是否让SpotBugs支持老的Java版本,结论是不提供支持。

经过以上的对比分析我们发现,工具的诞生都能针对性解决某一领域问题。CheckStyle的扫描速度快效率高,对代码风格和圈复杂度支持友好;FindBugs针对Java代码潜在问题,能帮助我们发现编码上的一些错误实践以及部分安全问题和性能问题;Lint是官方深度定制,功能极其强大,且可定制性和扩展性以及全面性都表现良好。所以综合考虑,针对思考一,我们的结论是整合三种扫描工具,充分利用每一个工具的领域特性。

思考二:是否可以优化扫描过程?

既然选择了整合这几种工具,我们面临的挑战是整合工具后扫描效率的问题,首先来分析目前的插件到底耗时在哪里。

静态代码扫描耗时分析

Android项目的构建依赖Gradle工具,一次构建过程实际上是执行所有的Gradle Task。由于Gradle的特性,在构建时各个Module都需要执行CheckStyle、FindBugs、Lint相关的Task。对于Android来说,Task的数量还与其构建变体Variant有关,其中Variant = Flavor * BuildType。所以一个Module执行的相关任务可以由以下公式来描述:Flavor * BuildType (Lint,CheckStyle,Findbugs),其中为笛卡尔积。如下图所示:

Android静态代码扫描效率优化与实践_美团_05

可以看到,一次构建全量扫描执行的Task跟Varint个数正相关。对于现有工程的任务,我们可以看一下目前各个任务的耗时情况:(以实际开发中某一次扫描为例)

Android静态代码扫描效率优化与实践_Android开发_06

通过对Task耗时排序,主要的耗时体现在FindBugs和Lint对每一个Module的扫描任务上,CheckStyle任务并不占主要影响。整体来看,除了工具本身的扫描时间外,耗时主要分为多Module多Variant带来的任务数量耗时。

优化思路分析

对于工具本身的扫描时间,一方面受工具自身扫描算法和检测规则的影响,另一方面也跟扫描的文件数量相关。针对源码类型的工具比如CheckStyle和Lint,需要经过词法分析、语法分析生成抽象语法树,再遍历抽象语法树跟定义的检测规则去匹配;而针对字节码文件的工具FindBugs,需要先编译源码成Class文件,再通过BCEL分析字节码指令并与探测器规则匹配。如果要在工具本身算法上去寻找优化点,代价比较大也不一定能找到有效思路,投入产出比不高,所以我们把精力放在减少Module和Variant带来的影响上。

从上面的耗时分析可以知道,Module和Variant数直接影响任务数量, 一次PR提交的场景是多样的,比如多Module多Variant都有修改,所以要考虑这些都修改的场景。先分析一个Module多Variant的场景,考虑到不同的Variant下源代码有一定差异,并且FindBugs扫描针对的是Class文件,不同的Variant都需要编译后才能扫描,直接对多Variant做处理比较复杂。我们可以简化问题,用以空间换时间的方式,在提交PR的时候根据Variant用不同的Jenkins Job来执行每一个Variant的扫描任务。所以接下来的问题就转变为如何优化在扫描单个Variant的时候多Module任务带来的耗时。

对于Module数而言,我们可以将其抽取成组件,拆分到独立仓库,将扫描任务拆分到各自仓库的变动时期,以aar的形式集成到主项目来减少Module带来的任务数。那对于剩下的Module如何优化呢?无论是哪一种工具,都是对其输入文件进行处理,CheckStyle对Java源代码文件处理,FindBugs对Java字节码文件处理,如果我们可以通过一次任务收集到所有Module的源码文件和编译后的字节码文件,我们就可以减少多Module的任务了。所以对于全量扫描,我们的主要目标是来解决如何一次性收集所有Module的目标文件

思考三:是否支持增量扫描?

上面的优化思路都是基于全量扫描的,解决的是多Module多Variant带来的任务数量耗时。前面提到,工具本身的扫描时间也跟扫描的文件数量有关,那么是否可以从扫描的文件数量来入手呢?考虑平时的开发场景,提交PR时只是部分文件修改,我们没必要把那些没修改过的存量文件再参与扫描,而只针对修改的增量文件扫描,这样能很大程度降低无效扫描带来的效率问题。有了思路,那么我们考虑以下几个问题:

  • 如何收集增量文件,包括源码文件和Class文件?

  • 现在业界是否有增量扫描的方案,可行性如何,是否适用我们现状?

  • 各个扫描工具如何来支持增量文件的扫描?

根据上面的分析与思考路径,接下来我们详细介绍如何解决上述问题。

优化探索与实践

全量扫描优化

搜集所有Module目标文件集

获取所有Module目标文件集,首先要找出哪些Module参与了扫描。一个Module工程在Gradle构建系统中被描述为一个“Project”,那么我们只需要找出主工程依赖的所有Project即可。由于依赖配置的多样性,我们可以选择在某些Variant下依赖不同的Module,所以获取参与一次构建时与当前Variant相关的Project对象,我们可以用如下方式:

static Set collectDepProject(Project project, BaseVariant variant, Set result = null) {
if (result == null) {
  result = new HashSet<>()
}
Set taskSet = variant.javaCompiler.taskDependencies.getDependencies(variant.javaCompiler)
taskSet.each { Task task ->
  if (task.project != project && hasAndroidPlugin(task.project)) {
    result.add(task.project)
    BaseVariant childVariant = getVariant(task.project)
    if (childVariant.name == variant.name || "${variant.flavorName}${childVariant.buildType.name}".toLowerCase() == variant.name.toLowerCase()) {
      collectDepProject(task.project, childVariant, result)
    }
  }
}
return result
}

目前文件集分为两类,一类是源码文件,另一类是字节码文件,分别可以如下处理:

projectSet.each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
  GradleUtils.getAndroidExtension(targetProject).sourceSets.all { AndroidSourceSet sourceSet ->
    if (!sourceSet.name.startsWith("test") && !sourceSet.name.startsWith(SdkConstants.FD_TEST)) {
      source sourceSet.java.srcDirs
    }
  }
}
}

注:上面的Source是CheckStyle Task的属性,用其来指定扫描的文件集合;

// 排除掉一些模板代码class文件
static final Collection defaultExcludes = (androidDataBindingExcludes + androidExcludes + butterKnifeExcludes + dagger2Excludes).asImmutable()

List allClassesFileTree = new ArrayList<>()
ConfigurableFileTree currentProjectClassesDir = project.fileTree(dir: variant.javaCompile.destinationDir, excludes: defaultExcludes)
allClassesFileTree.add(currentProjectClassesDir)
GradleUtils.collectDepProject(project, variant).each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
  // 可能有的工程没有Flavor只有buildType
    GradleUtils.getAndroidVariants(targetProject).each { BaseVariant targetProjectVariant ->
    if (targetProjectVariant.name == variant.name || "${targetProjectVariant.name}".toLowerCase() == variant.buildType.name.toLowerCase()) {
        allClassesFileTree.add(targetProject.fileTree(dir: targetProjectVariant.javaCompile.destinationDir, excludes: defaultExcludes))
    }
  }
}
}

注:收集到字节码文件集后,可以用通过FindBugsTask 的 Class 属性指定扫描,后文会详细介绍FindBugs Task相关属性。

对于Lint工具而言,相应的Lint Task并没有相关属性可以指定扫描文件,所以在全量扫描上,我们暂时没有针对Lint做优化。

全量扫描优化数据

通过对CheckStyle和FindBugs全量扫描的优化,我们将整体扫描时间由原来的9min降低到了5min左右。

Android静态代码扫描效率优化与实践_美团_07

增量扫描优化

由前面的思考分析我们知道,并不是所有的文件每次都需要参与扫描,所以我们可以通过增量扫描的方式来提高扫描效率。

增量扫描技术调研

在做具体技术方案之前,我们先调研一下业界的现有方案,调研如下:

Android静态代码扫描效率优化与实践_Android教程_08

针对Lint,我们可以借鉴现有实现思路,同时深入分析扫描原理,在3.x版本上寻找出增量扫描的解决方案。对于CheckStyle和FindBugs,我们需要了解工具的相关配置参数,为其指定特定的差异文件集合。

注:业界有一些增量扫描的案例,例如 diff_cover,此工具主要是对单元测试整体覆盖率的检测,以增量代码覆盖率作为一个指标来衡量项目的质量,但是这跟我们的静态代码分析的需求不太符合。它有一个比较好的思路是找出差异的代码行来分析覆盖率,粒度比较细。但是对于静态代码扫描,仅仅的差异行不足以完成上下文的语义分析,尤其是针对FindBugs这类需要分析字节码的工具,获取的差异行还需要经过编译成Class文件才能进行分析,方案并不可取。

寻找增量修改文件

增量扫描的第一步是获取待扫描的目标文件。我们可以通过git diff命令来获取差异文件,值得注意的是对于删除的文件和重命名的文件需要忽略,我们更关心新增和修改的文件,并且只需要获取差异文件的路径就好了。举个例子:git diff --name-only --diff-filter=dr commitHash1 commitHash2,以上命令意思是对比两次提交记录的差异文件并获取路径,过滤删除和重命名的文件。对于寻找本地仓库的差异文件上面的命令已经足够了,但是对于PR的情况还有一些复杂,需要对比本地代码与远程仓库目标分支的差异。集团的代码管理工具在Jenkins上有相应的插件,该插件默认提供了几个参数,我们需要用到以下两个:

  • ${targetBranch}:需要合入代码的目标分支地址;

  • ${sourceCommitHash}:需要提交的代码hash值。

通过这两个参数执行以下一系列命令来获取与远程目标分支的差异文件。

git remote add upstream ${upstreamGitUrl}
git fetch upstream ${targetBranch}
git diff --name-only --diff-filter=dr $sourceCommitHash upstream/$targetBranch
  1. 配置远程分支别名为UpStream,其中upstreamGitUrl可以在插件提供的配置属性中设置;

  2. 获取远程目标分支的更新;

  3. 比较分支差异获取文件路径。

通过以上方式,我们找到了增量修改文件集。

Lint扫描原理分析

在分析Lint增量扫描原理之前,先介绍一下Lint扫描的工作流程:

Android静态代码扫描效率优化与实践_Android教程_09

App Source Files

项目中的源文件,包括Java、XML、资源文件、proGuard等。

lint.xml

用于配置希望排除的任何 Lint 检查以及自定义问题严重级别,一般各个项目都会根据自身项目情况自定义的lint.xml来排除一些检查项。

lint Tool

一套完整的扫描工具用于对Android的代码结构进行分析,可以通过命令行、IDEA、Gradle命令三种方式运行lint工具。

lint Output

Lint扫描的输出结果。

从上面可以看出,Lint Tool就像一个加工厂,对投入进来的原料(源代码)进行加工处理(各种检测器分析),得到最终的产品(扫描结果)。Lint Tool作为一个扫描工具集,有多种使用方式。Android为我们提供了三种运行方式,分别是命令行、IDEA、Gradle任务。这三种方式最终都殊途同归,通过LintDriver来实现扫描。如下图所示:

Android静态代码扫描效率优化与实践_美团_10

为了方便查看源码,新建一个工程,在build.gradle脚本中,添加如下依赖:

compile 'com.android.tools.build:gradle:3.1.1'
compile 'com.android.tools.lint:lint-gradle:26.1.1'

我们可以得到如下所示的依赖:

Android静态代码扫描效率优化与实践_Android教程_11

lint-api-26.1.1

Lint工具集的一个封装,实现了一组API接口,用于启动Lint。

lint-checks-26.1.1

一组内建的检测器,用于对这种描述好Issue进行分析处理。

lint-26.1.1

可以看做是依赖上面两个jar形成的一个基于命令行的封装接口形成的脚手架工程,我们的命令行、Gradle任务都是继承自这个jar包中相关类来做的实现。

lint-gradle-26.1.1

可以看做是针对Gradle任务这种运行方式,基于lint-26.1.1做了一些封装类。

lint-gradle-api-26.1.1

真正Gradle Lint任务在执行时调用的入口。

在理解清楚了以上几个jar的关系和作用之后,我们可以发现Lint的核心库其实是前三个依赖。后面两个其实是基于脚手架,对Gradle这种运行方式做的封装。最核心的逻辑在LintDriver的Analyze方法中。

fun analyze() {

  ...省略部分代码...

   for (project in projects) {
       fireEvent(EventType.REGISTERED_PROJECT, project = project)
  }
   registerCustomDetectors(projects)

  ...省略部分代码...

   try {
       for (project in projects) {
           phase = 1

           val main = request.getMainProject(project)

           // The set of available detectors varies between projects
           computeDetectors(project)

           if (applicableDetectors.isEmpty()) {
               // No detectors enabled in this project: skip it
               continue
          }

           checkProject(project, main)
           if (isCanceled) {
               break
          }

           runExtraPhases(project, main)
      }
  } catch (throwable: Throwable) {
       // Process canceled etc
       if (!handleDetectorError(null, this, throwable)) {
           cancel()
      }
  }
  ...省略部分代码...
}

主要是以下三个重要步骤:

registerCustomDetectors(projects)

Lint为我们提供了许多内建的检测器,除此之外我们还可以自定义一些检测器,这些都需要注册进Lint工具用于对目标文件进行扫描。这个方法主要做以下几件事情:

  1. 遍历每一个Project和它的依赖Library工程,通过client.findRuleJars来找出自定义的jar包;

  2. 通过client.findGlobalRuleJars找出全局的自定义jar包,可以作用于每一个Android工程;

  3. 从找到的jarFiles列表中,解析出自定义的规则,并与内建的Registry一起合并为CompositeIssueRegistry;需要注意的是,自定义的Lint的jar包存放位置是build/intermediaters/lint目录,如果是需要每一个工程都生效,则存放位置为~/.android/lint/

computeDetectors(project)

这一步主要用来收集当前工程所有可用的检测器。

checkProject(project, main)

接下来这一步是最为关键的一步。在此方法中,调用runFileDetectors来进行文件扫描。Lint支持的扫描文件类型很多,因为是官方支持,所以针对Android工程支持的比较友好。一次Lint任务运行时,Lint的扫描范围主要由Scope来描述。具体表现在:

fun infer(projects: Collection?): EnumSet {
          if (projects == null || projects.isEmpty()) {
              return Scope.ALL
          }

          // Infer the scope
          var scope = EnumSet.noneOf(Scope::class.java)
          for (project in projects) {
              val subset = project.subset
              if (subset != null) {
                  for (file in subset) {
                      val name = file.name
                      if (name == ANDROID_MANIFEST_XML) {
                          scope.add(MANIFEST)
                      } else if (name.endsWith(DOT_XML)) {
                          scope.add(RESOURCE_FILE)
                      } else if (name.endsWith(DOT_JAVA) || name.endsWith(DOT_KT)) {
                          scope.add(JAVA_FILE)
                      } else if (name.endsWith(DOT_CLASS)) {
                          scope.add(CLASS_FILE)
                      } else if (name.endsWith(DOT_GRADLE)) {
                          scope.add(GRADLE_FILE)
                      } else if (name == OLD_PROGUARD_FILE || name == FN_PROJECT_PROGUARD_FILE) {
                          scope.add(PROGUARD_FILE)
                      } else if (name.endsWith(DOT_PROPERTIES)) {
                          scope.add(PROPERTY_FILE)
                      } else if (name.endsWith(DOT_PNG)) {
                          scope.add(BINARY_RESOURCE_FILE)
                      } else if (name == RES_FOLDER || file.parent == RES_FOLDER) {
                          scope.add(ALL_RESOURCE_FILES)
                          scope.add(RESOURCE_FILE)
                          scope.add(BINARY_RESOURCE_FILE)
                          scope.add(RESOURCE_FOLDER)
                      }
                  }
              } else {
                  // Specified a full project: just use the full project scope
                  scope = Scope.ALL
                  break
              }
          }
}

可以看到,如果Project的Subset为Null,Scope就为Scope.ALL,表示本次扫描会针对能检测的所有范围,相应地在扫描时也会用到所有全部的Detector来扫描文件;

如果Project的Subset不为Null,就遍历Subset的集合,找出Subset中的文件分别对应哪些范围。其实到这里我们已经可以知道,Subset就是我们增量扫描的突破点。接下来我们看一下runFileDetectors:

if(scope.contains(Scope.JAVA_FILE)||scope.contains(Scope.ALL_JAVA_FILES)){
val checks = union(scopeDetectors[Scope.JAVA_FILE],scopeDetectors[Scope.ALL_JAVA_FILES])
if (checks != null && !checks.isEmpty()) {
  val files = project.subset
  if (files != null) {
    checkIndividualJavaFiles(project, main, checks, files)
  } else {
    val sourceFolders = project.javaSourceFolders
    val testFolders = if (scope.contains(Scope.TEST_SOURCES))
    project.testSourceFolders
    else
    emptyList ()
    val generatedFolders = if (isCheckGeneratedSources)
    project.generatedSourceFolders
    else
    emptyList ()
    checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)
  }
}
}

这里更加明确,如果project.subset不为空,就对单独的Java文件扫描,否则,就对源码文件和测试目录以及自动生成的代码目录进行扫描。整个runFileDetectors的扫描顺序入下:

  1. Scope.MANIFEST

  2. Scope.ALL_RESOURCE_FILES)|| scope.contains(Scope.RESOURCE_FILE) ||

    scope.contains(Scope.RESOURCE_FOLDER) || scope.contains(Scope.BINARY_RESOURCE_FILE)

  3. scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)

  4. scope.contains(Scope.CLASS_FILE) || scope.contains(Scope.ALL_CLASS_FILES) ||

    scope.contains(Scope.JAVA_LIBRARIES)

  5. scope.contains(Scope.GRADLE_FILE)

  6. scope.contains(Scope.OTHER)

  7. scope.contains(Scope.PROGUARD_FILE)

  8. scope.contains(Scope.PROPERTY_FILE)

官方文档的描述顺序一致。

现在我们已经知道,增量扫描的突破点其实是需要构造project.subset对象。

    /**
    * Adds the given file to the list of files which should be checked in this
    * project. If no files are added, the whole project will be checked.
    *
    * @param file the file to be checked
    */
  public void addFile(@NonNull File file) {
      if (files == null) {
          files = new ArrayList<>();
      }
      files.add(file);
  }

  /**
    * The list of files to be checked in this project. If null, the whole
    * project should be checked.
    *
    * @return the subset of files to be checked, or null for the whole project
    */
  @Nullable
  public List getSubset() {
      return files;
  }

注释也很明确的说明了只要Files不为Null,就会扫描指定文件,否则扫描整个工程。

Android静态代码扫描效率优化与实践(下)

作者:美团技术团队 · 鸿耀
来源:https://blog.51cto.com/u_15197658/2768467

收起阅读 »

Python内存驻留机制

驻留下面举例介绍python中的驻留机制。 python内存驻留知道结果是什么吗?下面是执行结果:TrueFalseTrueTrue整型驻留执行结果:FalseTrueTrueTrue因为启动时,Python 将一个 -5~256 之间整数列表预加载(缓存)到...
继续阅读 »



字符串驻留机制在许多面向对象编程语言中都支持,比如Java、python、Ruby、PHP等,它是一种数据缓存机制,对不可变数据类型使用同一个内存地址,有效的节省了空间,本文主要介绍Python的内存驻留机制。

驻留

字符串驻留就是每个字符串只有一个副本,多个对象共享该副本,驻留只针对不可变数据类型,比如字符串,布尔值,数字等。在这些固定数据类型处理中,使用驻留可以有效节省时间和空间,当然在驻留池中创建或者插入新的内容会消耗一定的时间。

下面举例介绍python中的驻留机制。
python内存驻留

在Python对象及内存管理机制一文中介绍了python的参数传递以及以及内存管理机制,来看下面一段代码:

l1 = [1, 2, 3, 4]
l2 = [1, 2, 3, 4]
l3 = l2
print(l1 == l2)
print(l1 is l2)
print(l2 == l3)
print(l2 is l3)

知道结果是什么吗?下面是执行结果:

True
False
True
True

l1和l2内容相同,却指向了不同的内存地址,l2和l3之间使用等号赋值,所以指向了同一个对象。因为列表是可变对象,每创建一个列表,都会重新分配内存,列表对象是没有“内存驻留”机制的。下面来看不可变数据类型的驻留机制。

整型驻留

Jupyter或者控制台交互环境中执行下面代码:

a1 = 300
b1 = 300
c1 = b1
print(a1 is b1)
print(c1 is b1)

a2 = 200
b2 = 200
c2 = b2
print(a2 is b2)
print(c2 is b2)

执行结果:

False
True
True
True

可以发现a1和b1指向了不同的地址,a2和b2指向了相同的地址,这是为什么呢?

因为启动时,Python 将一个 -5~256 之间整数列表预加载(缓存)到内存中,我们在这个范围内创建一个整数对象时,python会自动引用缓存的对象,不会创建新的整数对象。

浮点型不支持:

a = 1.0
b = 1.0
print(a is b)
print(a == b)

# 结果
# False
# True

如果上面的代码在非交互环境,也就是将代码作为python脚本运行的结果是什么呢?(运行环境为python3.7)

True
True
True
True
True
True

全为True,没有明确的限定临界值,都进行了驻留操作。这是因为使用不同的环境时,代码的优化方式不同。

字符串驻留

Jupyter或者控制台交互环境中:

  • 满足标识符命名规范的字符串都会被驻留,长度不限。

  • 空字符串会驻留

  • 使用乘法得到的字符串且满足标识符命名规范的字符串:长度小于等于20会驻留(peephole优化),Python 3.7改为4096(AST优化器)。

  • 长度为1的特殊字符(ASCII 字符中的)会驻留

  • 空元组或者只有一个元素且元素范围为-5~256的元组会驻留

满足标识符命名规范的字符:

a = 'Hello World'
b = 'Hello World'
print(a is b)

a = 'Hello_World'
b = 'Hello_World'
print(a is b)

结果:

False
True

乘法获取字符串(运行环境为python3.7)

a = 'aa'*50
b = 'aa'*50
print(a is b)

a = 'aa'*5000
b = 'aa'*5000
print(a is b)

结果:

True
False

在非交互环境中:

  • 默认字符串都会驻留

  • 使用乘法运算得到的字符串与在控制台相同

  • 元组类型(元组内数据为不可变数据类型)会驻留

  • 函数、类、变量、参数等的名称以及关键字都会驻留

注意:字符串是在编译时进行驻留,也就是说,如果字符串的值不能在编译时进行计算,将不会驻留。比如下面的例子:

letter = 'd'
a = 'Hello World'
b = 'Hello World'
c = 'Hello Worl' + 'd'
d = 'Hello Worl' + letter
e = " ".join(['Hello','World'])

print(id(a))
print(id(b))
print(id(c))
print(id(d))
print(id(e))

在交互环境执行结果如下:

1696903309168
1696903310128
1696903269296
1696902074160
1696903282800

都指向不同的内存。

python 3.7 非交互环境执行结果:

1426394439728
1426394439728
1426394439728
1426394571504
1426394571440

发现d和e指向不同的内存,因为d和e不是在编译时计算的,而是在运行时计算的。前面的a = 'aa'*50是在编译时计算的。

强行驻留

除了上面介绍的python默认的驻留外,可以使用sys模块中的intern()函数来指定驻留内容

import sys
letter_d = 'd'
a = sys.intern('Hello World')
b = sys.intern('Hello World')
c = sys.intern('Hello Worl' + 'd')
d = sys.intern('Hello Worl' + letter)
e = sys.intern(" ".join(['Hello','World']))

print(id(a))
print(id(b))
print(id(c))
print(id(d))
print(id(e))

结果:

1940593568304
1940593568304
1940593568304
1940593568304
1940593568304

使用intern()后,都指向了相同的地址。

总结

本文主要介绍了python的内存驻留,内存驻留是python优化的一种策略,注意不同运行环境下优化策略不一样,不同的python版本也不相同。注意字符串是在编译时进行驻留。

作者:测试开发小记
来源:https://blog.51cto.com/u_15441270/4714515

收起阅读 »

安卓客服云集成机器人欢迎语

1.会话分配 给APP渠道指定全天机器人2.机器人欢迎语打开,并指定一个菜单3.代码部分/** * 保存欢迎语到本地 */ public void saveMessage(){ Message message = Messa...
继续阅读 »
1.会话分配 给APP渠道指定全天机器人


2.机器人欢迎语打开,并指定一个菜单


3.代码部分

/**
* 保存欢迎语到本地
*/
public void saveMessage(){
Message message = Message.createReceiveMessage(Message.Type.TXT);
String str = Preferences.getInstance().getRobotWelcome();
EMTextMessageBody body = null;
if(!isRobotMenu(str)){
body = new EMTextMessageBody(str);
}else{
try{
body = new EMTextMessageBody("");
JSONObject msgtype = new JSONObject(str);
message.setAttribute("msgtype",msgtype);
}catch (Exception e){
Log.e("RobotMenu","onError:"+e.getMessage());
}
}
message.setFrom(toChatUsername);
message.addBody(body);
message.setMsgTime(System.currentTimeMillis());
message.setStatus(Message.Status.SUCCESS);
message.setMsgId(UUID.randomUUID().toString());

ChatClient.getInstance().chatManager().saveMessage(message);
messageList.refresh();
}

/**
* 判断机器人欢迎语是否是菜单类型
*
* @return
*/
private boolean isRobotMenu(String str) {
try {
JSONObject json = new JSONObject(str);
JSONObject obj = json.getJSONObject("choice");
} catch (Exception e) {
return false;
}
return true;
}

public void getNewRobotWelcome(String toChatUsername, MessageList messageList) {
this.toChatUsername=toChatUsername;
new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient okHttpClient = new OkHttpClient();
//需要替换为自己的参数
String tenantid = "67386";//需要替换为自己的tenantid(管理员模式->账户->账户信息->租户ID一栏)
String orgname = "1473190314068186";//appkey # 前半部分
String appname = "kefuchannelapp67386";//appkey # 后半部分
String username = toChatUsername;//IM 服务号
String token = ChatClient.getInstance().accessToken();//用户token
// String url = "http://kefu.easemob.com/v1/webimplugin/tenants/robots/welcome?channelType=easemob&originType=app&tenantId=" + tenantid + "&orgName=" + orgname + "&appName=" + appname + "&userName=" + username + "&token=" + token;
String url = "https://kefu.easemob.com/v1/webimplugin/tenants/robots/welcome?channelType=easemob&originType=app&tenantId=95739&orgName=1404210708092119&appName=kefuchannelapp95739&userName=kefuchannelimid_548067&token=YWMtamWiyuByEeuml5FFEs_ewo740PDfnhHrjuLfDWx-sxgBU8F64G8R65kl_RFfGcMJAwMAAAF6iaJgxwBPGgDFrFY27hqYUwtUP5mDC0wRg1jcOkfkyEVs38cgDdmEQw";
Request request = new Request.Builder().url(url).get().build();
try {
Response response = okHttpClient.newCall(request).execute();
String result = response.body().string();
JSONObject obj = new JSONObject(result);
Log.e("newwelcome----", obj.getJSONObject("entity").getString("greetingText"));
int type = obj.getJSONObject("entity").getInt("greetingTextType");
final String rob_welcome = obj.getJSONObject("entity").getString("greetingText");
//type0代表是文字消息的机器人欢迎语
//type1代表是菜单消息的机器人欢迎语
if (type == 0) {
//把解析拿到的string保存在本地
Preferences.getInstance().setRobotWelcome(rob_welcome);

} else if (type == 1) {
final String str = rob_welcome.replaceAll("&amp;quot;", "\"");
JSONObject json = new JSONObject(str);
JSONObject ext = json.getJSONObject("ext");
final JSONObject msgtype = ext.getJSONObject("msgtype");
//把解析拿到的string保存在本地
Preferences.getInstance().setRobotWelcome(msgtype.toString());
}
} catch (JSONException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();

ChatClient.getInstance().chatManager().getCurrentSessionId(toChatUsername, new ValueCallBack() {
@Override
public void onSuccess(String value) {
Log.e("TAG value:", value);
//当返回value不为空时,则返回的当前会话的会话ID,也就是说会话正在咨询中,不需要发送欢迎语
if (value.isEmpty()) {//
saveMessage();
}
}

@Override
public void onError(int error, String errorMsg) {

}
});
}





收起阅读 »

统一路由,让小程序跳转更智能

我们在小程序开发及运营过程中,不可避免的需要进行页面之间的跳转。如果使用小程序自带的路由功能来实现这个功能,是非常简单的,如: // 根据不同的场景选择 navigateTo、redirectTo、switchTab 等 wx.navigateTo({ u...
继续阅读 »

我们在小程序开发及运营过程中,不可避免的需要进行页面之间的跳转。如果使用小程序自带的路由功能来实现这个功能,是非常简单的,如:


// 根据不同的场景选择 navigateTo、redirectTo、switchTab 等
wx.navigateTo({
url: "pages/somepage?id=1",
success: function (res) {},
});

但这里面存在几个问题:



  • 需要代码里面写死或者运营人员维护小程序页面的长长的具体路径,这显然是很不友好的

  • 需要知道页面是否为 tabbar 页面(switchTab)

  • 如果某个页面在 tabbar 和非 tabbar 页面之间发生了变化,或路径因为重构、主包瘦身等各种原因发生变化,原来的代码就会报错导致无法运行

  • navigateBack 不支持传参


为了解决以上问题,我们在项目中实现了一套基于命令别名(cmd)的统一路由跳转方式(以下称为统一路由),很好解决了遇到的实际问题,统一路由特点如下:



  • 页面别名声明使用注释方式,不侵入业务代码

  • 页面可以存在多个别名,方便新老版本页面的流量切换

  • 路由内自动判断是否 tabbar 页面,自行处理跳转及传参,业务代码无需关心

  • 支持纯 js api 的页面跳转及需要用户点击的任意类型跳转(如联系客服、打开小程序等等)

  • 对于页面栈中存在相同页面时,可以自动返回并根据参数是否相同决定是否需要刷新页面,可有效减少页面栈层级,规避小程序 10 层限制


实现思路


step1. 资源描述约定


小程序内的跳转类操作存在以下几种



  1. js api 直接可以操作的内部页面间跳转(wx.navigateTo、wx.navigateBack、wx.redirectTo、wx.reLaunch、wx.switchTab)

  2. js api 直接可以操作的打开微信原生功能的跳转(扫码、拨打电话等)

  3. 需要借助点击操作的跳转(如打开小程序及客服等需要 open-type 配合的场景 )


针对这三类操作,我们使用常见的 URL(统一资源定位系统)方式描述不同的待跳转资源



  1. 内部页面


https://host?cmd=${pagename}&param1=a  // 打开普通页面并传参,标准的H5容器也算在普通页面内


  1. 微信原生 API


https://host?cmd=nativeAPI&API=makePhoneCall&phoneNumber=123456  // 拨打电话
https://host?cmd=nativeAPI&API=scanCode&callback=scanCallback // 扫码并执行回调


  1. 需要借助按钮 open-type 的微信原生能力


https://host?cmd=nativeButtonAPI&openType=contact  // 在线客服


  1. 打开另一个小程序


https://host?cmd=miniProgram&appId=wx637bb****&path=pages/order/index&version=trial&uid=${uid} 


小程序跳转需要携带更多的参数,所以做了cmd的区分,这里实际会解析成 nativeButtonAPI 运行



step2. 在页面内定义需要的数据


在每个页面的顶部添加注释,注意 cmd 不能重复,支持多个 cmd。为了方便后续解析,我们的注释大体上遵循 JSDoc 注释规范


// pages/detail/index.tsx

/**
* @cmd detail, newdetail
* @description 详情
* @param skuid {number} skuid
*/

step3. 在编译阶段扫描并生成配置文件


根据入口文件的页面定义,匹配出需要的注释部分,使用 doctrine 解析需要的数据,解析后的数据如下:


// config/router.config.ts
export default {
index: {
description: "首页", // 页面描述
path: "/pages/index/index", // 真实路径
isTabbar: true, // 是否tabbar页面
ensureLogin: false, // 是否需要强制登录
},
detail: {
description: "详情",
path: "/pages/detail/index",
isTabbar: false,
ensureLogin: true,
},
};

这里顺便可以使用 param 等生成详细的页面名称及入参文档,提供给其他研发或运营同学使用。


step4. 资源描述解析为标准数据


根据上面的资源描述约定及扫描得到的配置文件,我们可以将其转换为方便在小程序内解析的数据定义,基本格式如下


{
origin: 'https://host?cmd=detail&skuid=1', // 原始数据
parsed: {
type: 'PAGE', // 类型,PAGE,NATIVE_API,NATIVE_BUTTON_API,UNKNOW
data: {
path: 'pages/detail/index', // 实际的页面路径,如果type是PAGE则会解析出此字段
action: undefined, // 动作,scanCode,makePhoneCall,openType,miniprogram ……。如果type是NATIVE_API,NATIVE_BUTTON_API,则会解析出此字段
params: {
skuid: '1' // 需要携带的参数
}
}
}
}

step5. 根据标准数据执行对应逻辑


由于我们的项目使用的是 Taro 框架,以下伪代码都是以 Taro 为例。


// utils/router.ts

// 用于解析原始链接为标准数据
const parseURL = (origin) => {
// balabala,一顿操作格式化成上文的数据
const data = {
...
};
return data;
};

// 执行除 NATIVE_BUTTON_API 之外的跳转
const routeURL = (origin) => {
const parsedData = parseURL(origin)
const {parsed: {type, data}} = parsedData

switch(type){
case 'PAGE':
...
break;
case 'NATIVE_API':
...
break;
case 'UNKNOW':
...
break;
}
};

export default {
parseURL,
routeURL,
};

对于需要点击的类型,我们需要借助 UI 组件实现


// components/router.tsx

import router from "/utils/router";
import { Button } from "@tarojs/components";
import Taro, { Component, eventCenter } from "@tarojs/taro";

export default class Router extends Component {
componentWillMount() {
const { path } = this.props;
const data = router.parseURL(path);
const { parsed, origin } = data;
const openType =
(parsed &&
parsed.data &&
parsed.data.params &&
parsed.data.params.openType) ||
false;
this.setState({
parsed,
openType,
});
}

// 点击事件
async handleClick(parsed, origin) {
// 点击执行动作
let {
type,
data: { action, params },
} = parsed;
if (!type) {
return;
}

// 内部页面
if (["PAGE", "CMD_UNKNOW"].includes(type)) {
console.log(`CMD_NATIVE_PAGE 参数:`, origin, options);
router.routeURL(origin);
return;
}

// 拨打电话、扫码等原生API
if (["NATIVE_API"].includes(type) && action) {
if (action === "makePhoneCall") {
let { phoneNumber = "" } = params;
if (!phoneNumber || phoneNumber.replace(/\s/g, "") == "") {
Taro.showToast({
icon: "none",
title: "未查询到号码,无法呼叫哦~",
});
return;
}
}

let res = await Taro[action]({ ...params });

// 扫码事件,需要在扫码完成后发送全局广播,业务内自行处理
if (action === "scanCode" && params.callback) {
let eventName = `${params.callback}_event`;
eventCenter.trigger(eventName, res);
}
}

// 打开小程序
if (
["NATIVE_BUTTON_API"].includes(type) &&
["miniprogram"].includes(action)
) {
await Taro.navigateToMiniProgram({
...params,
});
}
}

render() {
const { parsed, openType, origin } = this.state;

return (
<Button
onClick={this.handleClick.bind(this, parsed, origin)}
hoverClass="none"
openType={openType}
>
{this.props.children}
</Button>
);
}
}

在具体业务中使用


// pages/index/index.tsx
import router from "/utils/router";
import Router from "/components/router";

// js方式直接跳转
router.routeURL('https://host?cmd=detail&skuid=1')

// UI组件方式
...
render(){
return <Router path='https://host?cmd=detail&skuid=1'></Router>
}
...

当然这里面可以附加你自己需要的功能,比如:增加跳转方式控制、数据处理、埋点、加锁防连续点击,相对来说并不复杂。甚至你还可以顺手实现一下上面提到的 navigateBack 传参。


结语


上文的思考及实现过程比较简单,纯属抛砖引玉,欢迎大家交流互动。


作者:胖纳特
链接:https://juejin.cn/post/6930899487250448398

收起阅读 »

系统学习iOS动画 —— 渐变动画

iOS
系统学习iOS动画 —— 渐变动画这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战这个是希望达成的效果,主要就是下面字体的渐变动画以及右拉手势动画:先创建需要的控件:class ViewController: UIViewContro...
继续阅读 »

系统学习iOS动画 —— 渐变动画

这是我参与11月更文挑战的第22天,活动详情查看:2021最后一次更文挑战

这个是希望达成的效果,主要就是下面字体的渐变动画以及右拉手势动画:

请添加图片描述

先创建需要的控件:

class ViewController: UIViewController {
let timeLabel = UILabel()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.addSubview(timeLabel)
view.backgroundColor = .gray
timeLabel.text = "9:42"
timeLabel.font = UIFont.systemFont(ofSize: 72)
timeLabel.textColor = .white
timeLabel.frame = CGRect(x: 0, y: 100, width: timeLabel.intrinsicContentSize.width, height: timeLabel.intrinsicContentSize.height)
timeLabel.center.x = view.center.x

}


}

然后创建一个文件,然后写一个继承自UIView的类来编写动画的界面。

import UIKit
import QuartzCore

class AnimatedMaskLabel: UIView {

}

CAGradientLayer是CALayer的另一个子类,专门用于渐变的图层。这里创建一个CAGradientLayer来做渐变。这里

  • startPoint和endPoint定义了渐变的方向及其起点和终点
  • Colors是渐变的颜色数组
  • location: 每个渐变点的位置,范围 0 - 1 ,默认为0。

let gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()

// Configure the gradient here
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let colors = [
UIColor.yellow.cgColor,
UIColor.green.cgColor,
UIColor.orange.cgColor,
UIColor.cyan.cgColor,
UIColor.red.cgColor,
UIColor.yellow.cgColor
]
gradientLayer.colors = colors

let locations: [NSNumber] = [
0.0, 0.0, 0.0, 0.0, 0.0, 0.25
]
gradientLayer.locations = locations

return gradientLayer
}()


在layoutSubviews里面为gradient设置frame,这里设置宽度为三个屏幕宽度大小来让动画看起来更加顺滑。

 override func layoutSubviews() {
gradientLayer.frame = CGRect(
x: -bounds.size.width,
y: bounds.origin.y,
width: 3 * bounds.size.width,
height: bounds.size.height)
}

接着需要声明一个text,当text被赋值的时候,将文本渲染为图像,然后使用该图像在渐变图层上创建蒙版。

 var text: String! {
didSet {
setNeedsDisplay()

let image = UIGraphicsImageRenderer(size: bounds.size)
.image { _ in
text.draw(in: bounds, withAttributes: textAttributes)
}

let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image.cgImage

gradientLayer.mask = maskLayer
}
}

这里还需要为文本创建一个文本属性

  let textAttributes: [NSAttributedString.Key: Any] = {
let style = NSMutableParagraphStyle()
style.alignment = .center
return [
.font: UIFont(
name: "HelveticaNeue-Thin",
size: 28.0)!,
.paragraphStyle: style
]
}()

最后在didMoveToWindow中添加gradientLayer为自身子view并且为gradientLayer添加动画。

  override func didMoveToWindow() {
super.didMoveToWindow()
layer.addSublayer(gradientLayer)

let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.65, 0.8, 0.85, 0.9, 0.95, 1.0]
gradientAnimation.duration = 3.0
gradientAnimation.repeatCount = Float.infinity

gradientLayer.add(gradientAnimation, forKey: nil)
}

接下来在viewController中添加这个view。 声明一个animateLabel

    let animateLabel = AnimatedMaskLabel()

之后在viewDidLoad里面添加animateLabel在子view并且设置好各属性,这样animateLabel就有一个渐变动画了。

view.addSubview(animateLabel)
animateLabel.frame = CGRect(x: 0, y: UIScreen.main.bounds.size.height - 200, width: 200, height: 40)
animateLabel.center.x = view.center.x
animateLabel.backgroundColor = .clear
animateLabel.text = "Slide to reveal"

接下来为animateLabel添加滑动手势,这里设置滑动方向为向右滑动。

   let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSlide))
swipeGesture.direction = .right
animateLabel.addGestureRecognizer(swipeGesture)


然后在响应方法里面添加动画,这里先创建一个临时变量并且让其在屏幕外面,然后第一次动画的时候让timeLabel上移,animateLabel下移,然后让image跑到屏幕中间。完了之后在创建一个动画让timeLabel和animateLabel复原,把image移动到屏幕外,然后把image移除掉。

  @objc func handleSlide() {
// reveal the meme upon successful slide
let image = UIImageView(image: UIImage(named: "meme"))
image.center = view.center
image.center.x += view.bounds.size.width
view.addSubview(image)

UIView.animate(withDuration: 0.33, delay: 0.0,
animations: {
self.timeLabel.center.y -= 200.0
self.animateLabel.center.y += 200.0
image.center.x -= self.view.bounds.size.width
},
completion: nil
)

UIView.animate(withDuration: 0.33, delay: 1.0,
animations: {
self.timeLabel.center.y += 200.0
self.animateLabel.center.y -= 200.0
image.center.x += self.view.bounds.size.width
},
completion: {_ in
image.removeFromSuperview()
}
)
}

这样动画就完成了,完整代码:

import UIKit

class ViewController: UIViewController {
let timeLabel = UILabel()
let animateLabel = AnimatedMaskLabel()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.addSubview(timeLabel)
view.addSubview(animateLabel)

view.backgroundColor = .gray
timeLabel.text = "9:42"
timeLabel.font = UIFont.systemFont(ofSize: 72)
timeLabel.textColor = .white
timeLabel.frame = CGRect(x: 0, y: 100, width: timeLabel.intrinsicContentSize.width, height: timeLabel.intrinsicContentSize.height)
timeLabel.center.x = view.center.x


animateLabel.frame = CGRect(x: 0, y: UIScreen.main.bounds.size.height - 200, width: 200, height: 40)
animateLabel.center.x = view.center.x
animateLabel.backgroundColor = .clear
animateLabel.text = "Slide to reveal"
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSlide))
swipeGesture.direction = .right
animateLabel.addGestureRecognizer(swipeGesture)

}

@objc func handleSlide() {
// reveal the meme upon successful slide
let image = UIImageView(image: UIImage(named: "meme"))
image.center = view.center
image.center.x += view.bounds.size.width
view.addSubview(image)

UIView.animate(withDuration: 0.33, delay: 0.0,
animations: {
self.timeLabel.center.y -= 200.0
self.animateLabel.center.y += 200.0
image.center.x -= self.view.bounds.size.width
},
completion: nil
)

UIView.animate(withDuration: 0.33, delay: 1.0,
animations: {
self.timeLabel.center.y += 200.0
self.animateLabel.center.y -= 200.0
image.center.x += self.view.bounds.size.width
},
completion: {_ in
image.removeFromSuperview()
}
)
}

}


import UIKit
import QuartzCore


class AnimatedMaskLabel: UIView {

let gradientLayer: CAGradientLayer = {
let gradientLayer = CAGradientLayer()

// Configure the gradient here
gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
let colors = [
UIColor.yellow.cgColor,
UIColor.green.cgColor,
UIColor.orange.cgColor,
UIColor.cyan.cgColor,
UIColor.red.cgColor,
UIColor.yellow.cgColor
]
gradientLayer.colors = colors

let locations: [NSNumber] = [
0.0, 0.0, 0.0, 0.0, 0.0, 0.25
]
gradientLayer.locations = locations

return gradientLayer
}()

var text: String! {
didSet {
setNeedsDisplay()

let image = UIGraphicsImageRenderer(size: bounds.size)
.image { _ in
text.draw(in: bounds, withAttributes: textAttributes)
}

let maskLayer = CALayer()
maskLayer.backgroundColor = UIColor.clear.cgColor
maskLayer.frame = bounds.offsetBy(dx: bounds.size.width, dy: 0)
maskLayer.contents = image.cgImage

gradientLayer.mask = maskLayer
}
}

let textAttributes: [NSAttributedString.Key: Any] = {
let style = NSMutableParagraphStyle()
style.alignment = .center
return [
.font: UIFont(
name: "HelveticaNeue-Thin",
size: 28.0)!,
.paragraphStyle: style
]
}()

override func layoutSubviews() {
layer.borderColor = UIColor.green.cgColor
gradientLayer.frame = CGRect(
x: -bounds.size.width,
y: bounds.origin.y,
width: 3 * bounds.size.width,
height: bounds.size.height)
}

override func didMoveToWindow() {
super.didMoveToWindow()
layer.addSublayer(gradientLayer)

let gradientAnimation = CABasicAnimation(keyPath: "locations")
gradientAnimation.fromValue = [0.0, 0.0, 0.0, 0.0, 0.0, 0.25]
gradientAnimation.toValue = [0.65, 0.8, 0.85, 0.9, 0.95, 1.0]
gradientAnimation.duration = 3.0
gradientAnimation.repeatCount = Float.infinity

gradientLayer.add(gradientAnimation, forKey: nil)
}
}



收起阅读 »

iOS中加载xib

iOS
iOS中加载xib「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战」关于 xib 或 storyboard共同点都用来描述软件界面都用 interface builder 工具来编辑本质都是转换成代码去创建控件不同点xib是轻量级的...
继续阅读 »

iOS中加载xib

「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战

关于 xib 或 storyboard

  • 共同点
    • 都用来描述软件界面
    • 都用 interface builder 工具来编辑
    • 本质都是转换成代码去创建控件
  • 不同点
    • xib是轻量级的,用来描述局部UI界面
    • storyboard是重量级的,用来描述整个软件的多个界面,并且能够展示多个界面的跳转关系

加载xib

xib 文件在编译的后会变成 nib 文件

11975486-4f7dfbf345c0bff5.png

  • 第一种加载方式
    NSArray * xibArray = [[NSBundle mainBundle]loadNibNamed:NSStringFromClass(self) owner:nil options:nil] ;
    return xibArray[0];

  • 第二种加载方式
    UINib *nib = [UINib nibWithNibName:NSStringFromClass(self) bundle:nil];
    NSArray *xibArray = [nib instantiateWithOwner:nil options:nil];
    return xibArray[0];

    xibArray中log打印 log.png

控制器加载xib

  1. 首先需要对 xib 文件进行一些处理,打开 xib 文件

  2. 点击 "File‘s Owner",设置 Class 为 xxxViewControler 点击

  3. 右键 "Files‘s Owner",里面有个默认的IBOutlet变量view,看一下后面有没有做关联,如果没有就拉到下面的View和视图做个关联

    Files‘s Owner与View做关联

  • 第一种加载方式,传入指定的 xib(如CustomViewController)

    CustomViewController *custom = [[CustomViewController alloc]initWithNibName:@"CustomViewController" bundle:nil];

  • 第二种加载方式,不指定 xib

    CustomViewController *custom = [[CustomViewController alloc]initWithNibName:nil bundle:nil];

    • 第一步:寻找有没有和控制器类名同名的xib,如果有就去加载(XXViewController.xib)

      控制器类名同名的xib.png

    • 第二步:寻找有没有和控制器类名同名但是不带Controller的xib,如果有就去加载(XXView.xib)

      11975486-e40e19dd11cafbc5.png

    • 第三步:如果没有找到合适的 xib,就会创建一个 view(白色View,为系统自己创建的)


xib自定义控件与代码自定义的区别

这是自定义的一个 view,我们通过不同的初始化方式去判断它的执行方法

#import "CustomViw.h"
@implementation CustomViw
- (instancetype)init{
self = [super init];
if (self) {
NSLog(@"%s",__func__);
}
return self;
}

- (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) {
NSLog(@"%s",__func__);
}
return self;
}

- (instancetype)initWithCoder:(NSCoder *)aDecoder{

if (self = [super initWithCoder:aDecoder]) {
}
NSLog(@"%s",__func__);
return self;
}

- (void)awakeFromNib{
[super awakeFromNib];
NSLog(@"%s",__func__);
}
@end

  • 通过 init 方法初始化自定义控件

    @implementation ViewController
    - (void)viewDidLoad {
    [super viewDidLoad];
    CustomViw *customView = [[CustomViw alloc] init];
    }
    @end

    log:

    通过init方法初始化自定义控件log打印.png

  • 通过加载 xib 方法初始化自定义控件

    @implementation ViewController
    - (void)viewDidLoad {
    [super viewDidLoad];
    CustomViw *customView = [[[NSBundle mainBundle]loadNibNamed:NSStringFromClass([CustomViw class]) owner:nil options:nil] lastObject];
    }
    @end

    log(打印三次是因为CustomViw的xib文件里有三个View) 通过加载xib方法初始化自定义控件log打印.png

小结:

  • 通过代码初始化自定义控件是不会自动加载xib的,它会执行 initWithFrame 和 init
  • 通过加载 xib 初始化自定义控件,仅仅执行 initWithCoder 和 awakeFromNib,如果要通过代码修改 xib 的内容,一般建议放在 awakeFromNib 方法内

控件封装

一般封装一个控件,为了让开发者方便使用,通常会在自定义的控件中编写俩个方法初始化方法,这样不管是通过 init 还是加载xib都可以实现相同的效果

#import "CustomViw.h"
@implementation CustomViw

- (instancetype)initWithFrame:(CGRect)frame{
if (self = [super initWithFrame:frame]) {
[self setup];
}
return self;
}

- (void)awakeFromNib{
[super awakeFromNib];
[self setup];
}

- (void)setup{
[self setBackgroundColor:[UIColor redColor]];
}
@end

收起阅读 »

iOS中的Storyboard

iOS
iOS中的Storyboard「这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战」关于StoryboardStoryboard 是最先在 iOS5 中引入的一项新特性,它的出现使得开发人员大幅缩减构建App用户界面所需的时间关于Sto...
继续阅读 »


iOS中的Storyboard

「这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战

关于Storyboard

Storyboard 是最先在 iOS5 中引入的一项新特性,它的出现使得开发人员大幅缩减构建App用户界面所需的时间

关于Storyboard的加载方式

  • 一般在新建工程后,我们便可以看到Xcode会默认加载 Storyboard,但是在实际开发中,我们更常用的是自己新建 Storyboard,所以,这里主要讲手动创建控制器时,加载 Storyboard 的方式

  • 通常在新建的项目中,我们首先要将Xcode加载 Storyboard 去掉

    这里写图片描述

  • 关于 Storyboard 创建控制器

    第一种:

    这里写图片描述

    self.window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
    UIViewController *vc = [sb instantiateInitialViewController];
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];

    第二种:

    这里写图片描述

    self.window = [[UIWindow alloc]initWithFrame:[UIScreen mainScreen].bounds];
    UIStoryboard *sb = [UIStoryboard storyboardWithName:@"Main" bundle:nil];
    UIViewController *vc = [sb instantiateViewControllerWithIdentifier:@"WAKAKA"];
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];


关于UIStoryboardSegue

在 Storyboard 中,用来描述界面跳转的线,都属于 UIStoryboardSegue 的对象(简称:Segue

这里写图片描述

Segue的属性

  • 唯一标识(identifier
  • 来源控制器(sourceViewController
  • 目标控制器(destinationViewController

Segue的类型

  • 自动型(点击某控件,不需要进行某些判断可直接跳转的)

    这里写图片描述

  • 手动型(点击某控件,需要进行某些判断才跳转的) 这里写图片描述

  • 手动设置 Segue 需要设置

    这里写图片描述

    使用 perform 方法执行对应的 Segue

    //根据Identifier去storyboard中找到对应的线,之后建立一个storyboard的对象
    [self performSegueWithIdentifier:@"showinfo" sender:nil];

    如果需要做传值或跳转到不同的UI,需要在这个方法里代码实现

    - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{
    //比较唯一标识
    if ([segue.identifier isEqualToString:@"showInfo"]) {
    //来源控制器
    UINavigationController *nvc = segue.sourceViewController;
    //目的控制器
    ListViewController *vc = segue.destinationViewController;
    vc.info = @
    "show";
    }
    }

    链接:https://juejin.cn/post/7035408728509644814
收起阅读 »

iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式

iOS
iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战。引言例子:按照比例显示图片全部内容,并自动适应高度I 图片的平铺和拉伸 #import "UIImage+ResizableI...
继续阅读 »

iOS小技能: 图片的平铺和拉伸、图片的加载方式、内容模式

这是我参与11月更文挑战的第28天,活动详情查看:2021最后一次更文挑战

引言

例子:按照比例显示图片全部内容,并自动适应高度

I 图片的平铺和拉伸


#import "UIImage+ResizableImage.h"

@implementation UIImage (ResizableImage)


+ (UIImage*)resizableImageWithName:(NSString *)name {
NSLog(@"%s--%@",__func__,name);
UIImage *image = [UIImage imageNamed:name];
//裁剪图片方式一:
//Creates and returns a new image object with the specified cap values.
/*right cap is calculated as width - leftCapWidth - 1
bottom cap is calculated as height - topCapWidth - 1
*/

return [image stretchableImageWithLeftCapWidth:image.size.width*0.5 topCapHeight:image.size.height*0.5];
//方式二:
// CGFloat top = image.size.width*0.5f-1;
// CGFloat left = image.size.height*0.5f-1;
// UIEdgeInsets insets = UIEdgeInsetsMake(top, left, top, left);
// UIImage *capImage = [image resizableImageWithCapInsets:insets resizingMode:UIImageResizingModeTile];
//
}




/**
CGFloat top = 0; // 顶端盖高度
CGFloat bottom = 0 ; // 底端盖高度
CGFloat left = 0; // 左端盖宽度
CGFloat right = 0; // 右端盖宽度

// UIImageResizingModeStretch:拉伸模式,通过拉伸UIEdgeInsets指定的矩形区域来填充图片
// UIImageResizingModeTile:平铺模式,通过重复显示UIEdgeInsets指定的矩形区域来填充图片


@param img <#img description#>
@param top <#top description#>
@param left <#left description#>
@param bottom <#bottom description#>
@param right <#right description#>
@return <#return value description#>
*/

- (UIImage *) resizeImage:(UIImage *) img WithTop:(CGFloat) top WithLeft:(CGFloat) left WithBottom:(CGFloat) bottom WithRight:(CGFloat) right
{
UIImage * resizeImg = [img resizableImageWithCapInsets:UIEdgeInsetsMake(self.size.height * top, self.size.width * left, self.size.height * bottom, self.size.width * right) resizingMode:UIImageResizingModeStretch];

return resizeImg;
}



//返回一个可拉伸的图片
- (UIImage *)resizeWithImageName:(NSString *)name
{
UIImage *normal = [UIImage imageNamed:name];

// CGFloat w = normal.size.width * 0.5f ;
// CGFloat h = normal.size.height *0.5f ;

CGFloat w = normal.size.width*0.8;
CGFloat h = normal.size.height*0.8;
//传入上下左右不需要拉升的编剧,只拉伸中间部分
return [normal resizableImageWithCapInsets:UIEdgeInsetsMake(h, w, h, w)];

// [normal resizableImageWithCapInsets:UIEdgeInsetsMake(<#CGFloat top#>, <#CGFloat left#>, <#CGFloat bottom#>, <#CGFloat right#>)]

// 1 = width - leftCapWidth - right
// 1 = height - topCapHeight - bottom

//传入上下左右不需要拉升的编剧,只拉伸中间部分,并且传入模式(平铺/拉伸)
// [normal :<#(UIEdgeInsets)#> resizingMode:<#(UIImageResizingMode)#>]

//只用传入左边和顶部不需要拉伸的位置,系统会算出右边和底部不需要拉升的位置。并且中间有1X1的点用于拉伸或者平铺
// 1 = width - leftCapWidth - right
// 1 = height - topCapHeight - bottom
// return [normal stretchableImageWithLeftCapWidth:w topCapHeight:h];
}




@end


II 图片的加载方式

优先选择3x图像,而不是2x图像时使用initWithContentsOfFile

 NSString *path = [[NSBundle mainBundle] pathForResource:@"smallcat" ofType:@"png"];
UIImage *image = [[UIImage alloc]initWithContentsOfFile:path];
// 在ipone5 s、iphone6和iphone6 plus都是优先加载@3x的图片,如果没有@3x的图片,就优先加载@2x的图片



  • 优先加载@2x的图片
  • [UIImage imageNamed:@"smallcat"]

iphone5s和iphone6优先加载@2x的图片,iphone6 plus是加载@3x的图片。

加载图片注意点:如果图片比较小,并且使用非常频繁,可以使用imageName:(eg icon),如果图片比较大,并且使用比较少,可以使用imageWithContentsOfFile:(eg 引导页 相册)。 imageName:

  • 1、当对象销毁的时候,图片占用的内存不会随着一起销毁,内存由系统来管理,程序员不可控制
  • 2、加载的图片,占用的内存非常大
  • 3、相同的图片不会被重复加载到内存

imageWithContentsOfFile:

  • 1、当对象销毁的时候,图片占用的内存会随着一起销毁
  • 2、加载的图片占用的内存较小

3、相同的图片如果被多次加载就会占据多个内存空间

III 内容模式

首先了解下图片的内容模式

3.1 内容模式

  • UIViewContentModeScaleToFill

拉伸图片至填充整个UIImageView,图片的显示尺寸会和imageVew的尺寸一样 。

This will scale the image inside the image view to fill the entire boundaries of the image view.

  • UIViewContentModeScaleAspectFit

图片的显示尺寸不能超过imageView尺寸大小

This will make sure the image inside the image view will have the right aspect ratio and fits inside the image view’s boundaries.

  • UIViewContentModeScaleAspectFill

按照图片的原来宽高比进行缩放(展示图片最中间的内容),配合使用 tmpView.layer.masksToBounds = YES;

This will makes sure the image inside the image view will have the right aspect ratio and fills the entire boundaries of the image view. For this value to work properly, make sure that you have set the clipsToBounds property of the image view to YES.

  • UIViewContentModeScaleToFill : 直接拉伸图片至填充整个imageView

划重点:

  1. UIViewContentModeScaleAspectFit : 按照图片的原来宽高比进行缩放(一定要看到整张图片)

使用场景:信用卡图片的展示

在这里插入图片描述

  1. UIViewContentModeScaleAspectFill : 按照图片的原来宽高比进行缩放(只能图片最中间的内容)

引导页通常采用UIViewContentModeScaleAspectFill


// 内容模式
self.contentMode = UIViewContentModeScaleAspectFill;
// 超出边框的内容都剪掉
self.clipsToBounds = YES;




3.2 例子:商品详情页的实现

  • [商品详情页(按照图片原宽高比例显示图片全部内容,并自动适应高度)

](kunnan.blog.csdn.net/article/det…)

  • 背景图片的拉伸(中间空白的小矩形设置为可拉伸,保证有形状的地方不进行缩放)
- (void)awakeFromNib
{
[super awakeFromNib];
// 拉伸
// self.backgroundView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"bg_dealcell"]];
// 平铺
// self.backgroundColor = [UIColor colorWithPatternImage:[UIImage imageNamed:@"bg_dealcell"]];


[self setAutoresizingMask:UIViewAutoresizingNone];



}




- (void)drawRect:(CGRect)rect
{
// 平铺
// [[UIImage imageNamed:@"bg_dealcell"] drawAsPatternInRect:rect];
// 拉伸
[[UIImage imageNamed:@"bg_dealcell"] drawInRect:rect];
}



背景图片的拉伸(中间空白的小矩形设置为可拉伸,保证有形状的地方不进行缩放)
UIImage *resizableImage = [image resizableImageWithCapInsets:UIEdgeInsetsMake(heightForLeftORRight, widthForTopORBottom, heightForLeftORRight, widthForTopORBottom)];

see also

只为你呈现有价值的信息,专注于移动端技术研究领域;更多服务和咨询请关注#公众号:iOS逆向

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

收起阅读 »

图解-元宇宙(MetaVerse)

目录 1、前言 2、元宇宙是什么 3、生态技术图谱 1、前言 近日,全球互联网巨头Facebook宣布改名为Meta(Meta为元宇宙MetaVerse的前缀),一时间,基于技术创新且未来空间广阔的“元宇宙”再次成为科技界最关心的话题。 2、元宇宙是什么 元...
继续阅读 »

目录


1、前言


2、元宇宙是什么


3、生态技术图谱




1、前言


近日,全球互联网巨头Facebook宣布改名为Meta(Meta为元宇宙MetaVerse的前缀),一时间,基于技术创新且未来空间广阔的“元宇宙”再次成为科技界最关心的话题。


2、元宇宙是什么


元宇宙(Metaverse)一词,诞生于1992年的科幻小说《雪崩》,小说描绘了一个庞大的虚拟现实世界,在这里,人们用数字化身来控制,并相互竞争以提高自己的地位,到现在看来,描述的还是超前的未来世界。


Metaverse是由Meta和Verse组成,Meta表示超越,verse是宇宙universe的意思,合起来通常表示,互联网的下一个阶段,由AR,VR,3D等技术支持的虚拟现实的网络世界。


3、生态技术图谱


元宇宙生态包含了从技术基础到各种支持系统的项目,生态图谱极其庞大。



图片来源:悦财经





作者:Meng
链接:https://juejin.cn/post/7032217003486085133
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

必学必知的自定义View基础

前言自定义View原理是Android开发者必须了解的基础;在了解自定义View之前,你需要有一定的知识储备;本文将全面解析关于自定义View中的所有知识基础。目录1. 视图定义即日常说的View,具体表现为显示在屏幕上的各种视图控件,如TextView、Li...
继续阅读 »

前言

  • 自定义View原理是Android开发者必须了解的基础;
  • 在了解自定义View之前,你需要有一定的知识储备;
  • 本文将全面解析关于自定义View中的所有知识基础。

目录

示意图


1. 视图定义

即日常说的View,具体表现为显示在屏幕上的各种视图控件,如TextView、LinearLayout等。


2. 视图分类

视图View主要分为两类:

  • 单一视图:即一个View、不包含子View,如TextView
  • 视图组,即多个View组成的ViewGroup、包含子View,如LinearLayout

Android中的UI组件都由View、ViewGroup共同组成。


3. 视图类简介

  • 视图的核心类是:View类
  • View类是Android中各种组件的基类,如View是ViewGroup基类
  • View的构造函数:共有4个,具体如下:

自定义View必须重写至少一个构造函数:

// 构造函数1
// 调用场景:View是在Java代码里面new的
public CarsonView(Context context) {
super(context);
}

// 构造函数2
// 调用场景:View是在.xml里声明的
// 自定义属性是从AttributeSet参数传进来的
public CarsonView(Context context, AttributeSet attrs) {
super(context, attrs);
}

// 构造函数3
// 应用场景:View有style属性时
// 一般是在第二个构造函数里主动调用;不会自动调用
public CarsonView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

// 构造函数4
// 应用场景:View有style属性时、API21之后才使用
// 一般是在第二个构造函数里主动调用;不会自动调用
public CarsonView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}

更加具体的使用请看:深入理解View的构造函数和 理解View的构造函数


4. 视图结构

  • 对于包含子View的视图组(ViewGroup),结构是树形结构
  • ViewGroup下可能有多个ViewGroup或View,如下图:

这里需要特别注意的是:在View的绘制过程中,永远都是从View树结构的根节点开始(即从树的顶端开始),一层一层、一个个分支地自上而下遍历进行(即树形递归),最终计算整个View树中各个View,从而最终确定整个View树的相关属性。


5. Android坐标系

Android的坐标系定义为:

  • 屏幕的左上角为坐标原点
  • 向右为x轴增大方向
  • 向下为y轴增大方向

具体如下图:

注:区别于一般的数学坐标系

两者坐标系的区别


6. View位置(坐标)描述

视图的位置由四个顶点决定,如图1-3所示的A、B、C、D。

视图的位置是相对于父控件而言的,四个顶点的位置描述分别由四个与父控件相关的值决定:

  • 顶部(Top):视图上边界到父控件上边界的距离;
  • 左边(Left):视图左边界到父控件左边界的距离;
  • 右边(Right):视图右边界到父控件左边界的距离;
  • 底部(Bottom):视图下边界到父控件上边界的距离。

具体如图1-4所示。

可根据视图位置的左上顶点、右下顶点进行记忆:

  • 顶部(Top):视图左上顶点到父控件上边界的距离;
  • 左边(Left):视图左上顶点到父控件左边界的距离;
  • 右边(Right):视图右下顶点到父控件左边界的距离;
  • 底部(Bottom):视图右下顶点到父控件上边界的距离。

7. 位置获取方式

视图的位置获取是通过View.getXXX()方法进行获取。

获取顶部距离(Top):getTop()
获取左边距离(Left):getLeft()
获取右边距离(Right):getRight()
获取底部距离(Bottom):getBottom()
  • 与MotionEvent中 get() getRaw()的区别
//get() :触摸点相对于其所在组件坐标系的坐标
event.getX();
event.getY();

//getRaw() :触摸点相对于屏幕默认坐标系的坐标
event.getRawX();
event.getRawY();

具体如下图:

get() 和 getRaw() 的区别


8. 角度(angle)& 弧度(radian)

  • 自定义View实际上是将一些简单的形状通过计算,从而组合到一起形成的效果。

这会涉及到画布的相关操作(旋转)、正余弦函数计算等,即会涉及到角度(angle)与弧度(radian)的相关知识。

  • 角度和弧度都是描述角的一种度量单位,区别如下图::

角度和弧度区别

在默认的屏幕坐标系中角度增大方向为顺时针。

屏幕坐标系角度增大方向

注:在常见的数学坐标系中角度增大方向为逆时针


9. 颜色相关

Android中的颜色相关内容包括颜色模式,创建颜色的方式,以及颜色的混合模式等。

9.1 颜色模式

Android支持的颜色模式主要包括:

  • ARGB8888:四通道高精度(32位)
  • ARGB4444:四通道低精度(16位)
  • RGB565:Android屏幕默认模式(16位)
  • Alpha8:仅有透明通道(8位)

这里需要特别注意的是:

  • 字母:表示通道类型;
  • 数值:表示该类型用多少位二进制来描述;
  • 示例说明:ARGB8888,表示有四个通道(ARGB);每个对应的通道均用8位来描述。

以ARGB8888为例介绍颜色定义:

ARGB88888

9.2 颜色定义

主要分为xml定义 / java定义。

/**
* 定义方式1:xml
* 在/res/values/color.xml文件中定义
*/
<?xml version="1.0" encoding="utf-8"?>
<resources>
//定义了红色(没有alpha(透明)通道)
<color name="red">#ff0000</color>
//定义了蓝色(没有alpha(透明)通道)
<color name="green">#00ff00</color>
</resources>

// 在xml文件中以”#“开头定义颜色,后面跟十六进制的值,有如下几种定义方式:
#f00 //低精度 - 不带透明通道红色
#af00 //低精度 - 带透明通道红色
#ff0000 //高精度 - 不带透明通道红色
#aaff0000 //高精度 - 带透明通道红色

/**
* 定义方式2:Java
*/
// 使用Color类定义颜色
int color = Color.GRAY; //灰色

// Color类使用ARGB值表示
int color = Color.argb(127, 255, 0, 0); //半透明红色
int color = 0xaaff0000; //带有透明度的红色

9.3 颜色引用

主要分为xml定义 / java定义。

/**
* 引用方式1:xml
*/
// 1. 在style文件中引用
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">@color/red</item>
</style>
// 2. 在layout文件中引用
android:background="@color/red"
// 3. 在layout文件中创建并使用颜色
android:background="#ff0000"

/**
* 引用方式2:Java
*/
//方法1
int color = getResources().getColor(R.color.mycolor);

//方法2(API 23及以上)
int color = getColor(R.color.myColor);

9.4 取色工具

  • 颜色都是用RGB值定义的,而我们一般是无法直观的知道自己需要颜色的值,需要借用取色工具直接从图片或者其他地方获取颜色的RGB值。
  • 有时候一些简单的颜色选取就不用去麻烦UI了,开发者自己去选取效率更高
  • 这里,取色工具我强推Markman:一款设计师用于标注的工具,主要用于尺寸标注、字体大小标注、颜色标注,而且使用简单。本人强烈推荐!




收起阅读 »

如何美化checkbox

前言 对于前端开发人员,checkbox应该是经常见到的东西。利用checkbox的checked属性,我们可以做出很多精彩的效果,之前还用checkbox来做动画暂停。前几天还看到外国大佬使用 checkbok做游戏:http://www.bryanbrau...
继续阅读 »

前言


对于前端开发人员,checkbox应该是经常见到的东西。利用checkbox的checked属性,我们可以做出很多精彩的效果,之前还用checkbox来做动画暂停。前几天还看到外国大佬使用 checkbok做游戏:http://www.bryanbraun.com/2021/09/21/… ,真的是佩服的五体投地,不过对于我这种菜鸡选手,还是只能实现一些简单的东西。对于下面的这个switch按钮,大家应该非常熟悉了,同样的在这个效果上还衍生出了各种华丽花哨的效果,例如暗黑模式的切换。一生万,掌握了一,万!还不是手到擒来。


image-20211128233027149


推荐大家看看codepen上的这个仓库:文章封面的效果,也是从这里录制的!
tql

codepen.io/oliviale/pe…


image-20211128235343983


标签


这里使用for将label和input捆绑


<input type="checkbox" id="toggle" />
<label for="toggle"></label>

同时设置input不可见


input {
display: none;
}

美化label


遇到checkbox的美化问题,基本上都是考虑用美化labl替代美化input。


设置背景颜色,宽高,以及圆角


.switch {
  display: inline-block;
  display:relative;
  width: 40px;
  height: 20px;
  background-color: rgba(0, 0, 0, 0.25);
  border-radius: 20px;
}


最终的效果如下:


image-20211128233616100


切换的圆


在label上会有一个圆,一开始是在左边的,效果如下,其实这个只需要利用伪元素+positon定位,就可以实现了。


image-20211128233732168


这是postion:absolute,同时将位置定位在top1px,left1px。同时设置圆角。


      .switch:after {
      content: "";
      position: absolute;
      width: 18px;
      height: 18px;
      border-radius: 18px;
      background-color: white;
      top: 1px;
      left: 1px;
      transition: all 0.3s;
    }

checked+小球右移动


这里点击之后圆会跑到右边,这里有两种实现方案


1.仍然通过定位


当checkbox处于checked状态,会设置top,left,bottom,right。这里将top,left设置为auto是必须的,这种的好处就是,不需要考虑label的宽度。


  input[type="checkbox"]:checked + .switch:after {
      top: auto;
      left: auto;
      bottom: 1px ;
      right: 1px ;
    }

当然知道label的宽度可以直接,设置top和left


top: 1px;
left: 21px;

2.translateX


*transform: translateX(20px)*

美化切换后的label


加上背景色


input[type="checkbox"]:checked + .switch {
background-color: #7983ff;
}

效果:


switch


后记


看上去本文是一篇介绍一个checkbox美化的效果,其实是一篇告诉你如何美化checkbox的文章,最终的思想就是依赖for的捆绑效果,美化label来达到最终的效果。


作者:半夏的故事
链接:https://juejin.cn/post/7035650204829220877

收起阅读 »

CoordinatorLayout与AppBarLayout。置顶悬停,二级悬停,类似京东、淘宝等二级悬停。

类似京东、淘宝等二级悬停。 参考+实践 一、惯例先上效果图 二、GitHub 代码地址,欢迎指正https://github.com/MNXP/SlideTop 三、XML布局主要用到的控件 1、PullRefreshLayout (借用这位大神的ht...
继续阅读 »

类似京东、淘宝等二级悬停。
参考+实践




一、惯例先上效果图


效果图


二、GitHub

三、XML布局主要用到的控件


  1、PullRefreshLayout (借用这位大神的https://github.com/genius158/PullRefreshLayout)
2、CoordinatorLayout
3、AppBarLayout

四、实现

1、布局的实现



  需要注意的几点:
1)AppBarLayout 设置 behavior 需要自己定义,为以后拦截事件用
app:layout_behavior=".weight.MyBehavior"
2)AppBarLayout 第一个子view,就是需要滑动消失的布局,设置
app:layout_scrollFlags="scroll|exitUntilCollapsed"
scroll 滚动,exitUntilCollapsed 可以在置顶后有阴影效果
3)最外层RecyclerView(也可以是各种带滑动的view,也可以是ViewPager实现分页) 设置
app:layout_behavior="@string/appbar_scrolling_view_behavior"


xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

android:layout_width="match_parent"
android:layout_height="60dp"
android:background="#ffffff"
android:orientation="vertical">

android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="置顶滑动"
android:textColor="@color/black"
android:textSize="20sp" />
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_alignParentBottom="true"
android:background="#dddddd"/>


android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:prl_pullDownMaxDistance="300dp"
app:prl_twinkEnable="true">

android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/home_top_view"
android:orientation="vertical">

android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ffffff"
app:layout_behavior=".weight.MyBehavior">

android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
android:layout_width="match_parent"
android:layout_height="150dp"
android:src="@mipmap/home_c"/>
android:id="@+id/top_img_rv"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>


android:id="@+id/home_tab_container_layout"
android:layout_width="match_parent"
android:layout_height="55dp"
android:gravity="center"
android:orientation="horizontal">
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:layout_marginLeft="20dp"
android:textSize="15sp"
android:textColor="#222222"
android:text="悬停标题"/>

android:id="@+id/filter_layout"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:layout_marginEnd="20dp"
android:scaleType="fitXY"
android:src="@mipmap/home_icon" />




android:id="@+id/bottom_img_rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />








2、首先解决PullRefreshLayout 与 CoordinatorLayout(依靠AppBarLayout来处理)滑动冲突


通过AppBarLayout监听addOnOffsetChangedListener获取CoordinatorLayout是否滑动到顶部,
设置PullRefreshLayout是否可以上拉刷新

   // 记录AppBar滚动距离
appBarLayout.addOnOffsetChangedListener(View::setTag);
homeRefreshLayout.setOnTargetScrollCheckListener(new PullRefreshLayout.OnTargetScrollCheckListener() {
@Override
public boolean onScrollUpAbleCheck() {
// 根据AppBar滚动的距离来设置RefreshLayout是否可以下拉刷新
int appbarOffset = ((appBarLayout.getTag() instanceof Integer)) ? (int) appBarLayout.getTag() : 0;
return appbarOffset != 0;
}

@Override
public boolean onScrollDownAbleCheck() {
return true;
}
});

3、启用AppBarLayout滑动(不设置也可以,但有的时候会滑动有问题)


注意📢:要在数据加载之后设置,不然不起作用

CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams();
MyBehavior behavior = (MyBehavior) params.getBehavior();
try {
if (behavior!=null){
behavior.setDragCallback(new AppBarLayout.Behavior.DragCallback() {
@Override
public boolean canDrag(@NonNull AppBarLayout appBarLayout) {
isFirstData = true;
//为了启用折叠工具栏的滚动
return true;
}
});
}

} catch (Exception e) {

}

4、自以为很完美了┭┮﹏┭┮,但是遇到一个小问题


  问题?
滑动置顶之后,滑动下面的recyclerview,使得recyclerview不是显示第一个item,松开手,
然后向下滑动“悬停标题”,发现可以向下滑动,🤩,是bug的味道。如下图

BUG的味道


下面就开始解决


 解决思路就是根据下面的RecyclerView滑动,设置AppBarLayout是否可以滑动,
(1) 设置监听RecyclerView第一个完整item
(2) 根据Position来设置behavior.setCanMove(position<1);
(3) MyBehavior实现是否可以滑动
上代码

// (1)设置监听RecyclerView第一个完整item
bottomRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == SCROLL_STATE_IDLE) {
if (bottomRv != null && bottomRv.getLayoutManager() instanceof LinearLayoutManager) {
LinearLayoutManager layoutManager = (LinearLayoutManager) bottomRv.getLayoutManager();
if (layoutManager != null) {
// 根据滑动item设置置顶是否可以滑动
int firstCompletelyVisible = layoutManager.findFirstCompletelyVisibleItemPosition();
initAppbar(firstCompletelyVisible);
}
}
}
}
});
//根据Position来设置behavior.setCanMove(position<1);
private boolean isFirstData;
private int oldPosition = -2;
public void initAppbar(int position) {
if (oldPosition == position){
return;
}
oldPosition = position;
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams();
MyBehavior behavior = (MyBehavior) params.getBehavior();
try {
if (behavior!=null){
if (position == -1){
behavior.setDragCallback(new AppBarLayout.Behavior.DragCallback() {
@Override
public boolean canDrag(@NonNull AppBarLayout appBarLayout) {
isFirstData = true;
//为了启用折叠工具栏的滚动
return true;
}
});
}else {
// 置顶后,如果recyclerview不是第一个item,禁止工具栏滑动
behavior.setCanMove(position<1);
}
}

} catch (Exception e) {
}
}
// (3) MyBehavior实现是否可以滑动
@Override
public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull AppBarLayout child, @NonNull MotionEvent ev) {
if (!canMove && ev.getAction() == MotionEvent.ACTION_DOWN){
return false;
}
return super.onInterceptTouchEvent(parent, child, ev);
}

public void setCanMove(boolean canMove){
this.canMove = canMove;
}

5、完整代码


Activity代码

  public class MainActivity extends AppCompatActivity {
private RecyclerView topRv;
private RecyclerView bottomRv;
private AppBarLayout appBarLayout;
private PullRefreshLayout homeRefreshLayout;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
appBarLayout = findViewById(R.id.app_bar_layout);
homeRefreshLayout = findViewById(R.id.swipe_refresh_layout);
topRv = findViewById(R.id.top_img_rv);
bottomRv = findViewById(R.id.bottom_img_rv);
topRv.setLayoutManager(new LinearLayoutManager(this));
bottomRv.setLayoutManager(new LinearLayoutManager(this));
initView();
initData();
}
private void initView() {
StoreHouseHeader header = new StoreHouseHeader(this);
header.setPadding(0, 20, 0, 20);
header.initWithString("XIANGPAN");
header.setTextColor(0xFF222222);

homeRefreshLayout.setHeaderView(header);

homeRefreshLayout.setOnRefreshListener(new PullRefreshLayout.OnRefreshListenerAdapter() {
@Override
public void onRefresh() {
initData();
checkHandler.sendEmptyMessageDelayed(0,2000);
}
});
// 记录AppBar滚动距离
appBarLayout.addOnOffsetChangedListener(View::setTag);

homeRefreshLayout.setOnTargetScrollCheckListener(new PullRefreshLayout.OnTargetScrollCheckListener() {
@Override
public boolean onScrollUpAbleCheck() {
// 根据AppBar滚动的距离来设置RefreshLayout是否可以下拉刷新
int appbarOffset = ((appBarLayout.getTag() instanceof Integer)) ? (int) appBarLayout.getTag() : 0;
return appbarOffset != 0;
}

@Override
public boolean onScrollDownAbleCheck() {
return true;
}
});

}
private void initData() {
initTop();
initBottom();
if (!isFirstData) {
initAppbar(-1);
}

appBarLayout.setExpanded(true, false);
}

private void initBottom() {
PhotoAdapter bottomAdapter = new PhotoAdapter();
bottomRv.setAdapter(bottomAdapter);
bottomAdapter.setDataList(10);
bottomRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == SCROLL_STATE_IDLE) {
if (bottomRv != null && bottomRv.getLayoutManager() instanceof LinearLayoutManager) {
LinearLayoutManager layoutManager = (LinearLayoutManager) bottomRv.getLayoutManager();
if (layoutManager != null) {
// 根据滑动item设置置顶是否可以滑动
int firstCompletelyVisible = layoutManager.findFirstCompletelyVisibleItemPosition();
initAppbar(firstCompletelyVisible);
}
}
}
}
});
}

private void initTop() {
PhotoAdapter topAdapter = new PhotoAdapter();
topRv.setAdapter(topAdapter);
topAdapter.setDataList(4);
}
private boolean isFirstData;
private int oldPosition = -2;
public void initAppbar(int position) {
if (oldPosition == position){
return;
}
oldPosition = position;
CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams();
MyBehavior behavior = (MyBehavior) params.getBehavior();
try {
if (behavior!=null){
if (position == -1){
behavior.setDragCallback(new AppBarLayout.Behavior.DragCallback() {
@Override
public boolean canDrag(@NonNull AppBarLayout appBarLayout) {
isFirstData = true;
//为了启用折叠工具栏的滚动
return true;
}
});
}else {
// 置顶后,如果recyclerview不是第一个item,禁止工具栏滑动
behavior.setCanMove(position<1);
}
}

} catch (Exception e) {

}

}
public Handler checkHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
//模拟网络请求结束,去除刷新效果
if (homeRefreshLayout != null) {
homeRefreshLayout.refreshComplete();
}
}
};
}

MyBehavior代码

public class MyBehavior extends AppBarLayout.Behavior {


private boolean canMove = true;

public MyBehavior() {

}

public MyBehavior(Context context, AttributeSet attrs) {

super(context, attrs);
}

@Override
public boolean onInterceptTouchEvent(@NonNull CoordinatorLayout parent, @NonNull AppBarLayout child, @NonNull MotionEvent ev) {
if (!canMove && ev.getAction() == MotionEvent.ACTION_DOWN){
return false;
}
return super.onInterceptTouchEvent(parent, child, ev);
}

public void setCanMove(boolean canMove){
this.canMove = canMove;
}

public boolean isCanMove() {
return canMove;
}
}



以上就是全部内容,待完善,以后会更新。如有建议和意见,请及时沟通。


作者:_xiangpan
链接:https://juejin.cn/post/7018453099794825252
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Metaverse 已经到来:5 家公司正在构建我们的虚拟现实未来

如果你相信 Facebook,未来就是一个虚拟现实的“元宇宙”。这家上个月更名为 Meta的科技巨头计划今年投资100 亿美元来开发支持增强现实和虚拟现实的产品——机械手、高科技 VR 眼镜和复杂的软件应用程序,仅举几例。分析师预计该公司至少要花费 500 亿...
继续阅读 »

如果你相信 Facebook,未来就是一个虚拟现实的“元宇宙”

这家上个月更名为 Meta的科技巨头计划今年投资100 亿美元来开发支持增强现实虚拟现实的产品——机械手高科技 VR 眼镜和复杂的软件应用程序,仅举几例。分析师预计该公司至少要花费 500 亿美元来实现其对虚拟现实未来的承诺。

但 Meta 远非唯一的玩家。事实上,六家其他公司已经在构建将成为下一代虚拟交互的硬件和软件——华尔街认为这是一个价值 1 万亿美元的市场。这些公司包括谷歌、微软、苹果、Valve 和其他开发工作和通信产品的公司。随着投资者涌入市场,规模较小的初创公司可能会加入他们的行列。

“元宇宙是真实的,华尔街正在寻找赢家,”韦德布什分析师丹艾夫斯在一份报告中说。

在 Facebook 试图在元领域打上烙印时,这些公司的产品将不得不与之抗衡。

谷歌

Google Cardboard 可能是历史上最成功的 VR 项目。2014 年,当时世界上最大的科技公司要求数百万人用一块硬纸板将智能手机绑在脸上。谷歌表示,它出货了“数千万”可折叠耳机,谷歌 Cardboard应用程序的下载量超过 1.6 亿次。这不是最高分辨率或高科技的体验,但该策略帮助向数百万学生有抱负的开发人员介绍了虚拟现实。

它还帮助谷歌摆脱了之前的增强现实实验Glass今天,增强现实眼镜作为企业业务的工具进行销售,但当它推出时,谷歌的期望值很高。字面意思是:谷歌创始人谢尔盖·布林 (Sergey Brin) 从飞机上跳下时宣布了 1,500 美元的产品。

玻璃本质上是智能手机的内脏,在非处方眼镜框架上安装了一个小型摄像头。该项目失败了,但在催生了无数模因之前就失败了。 

微软

微软于 2015 年发布Hololens混合现实眼镜。一年后,微软并没有用营销炒作充斥消费者市场,而是悄悄推出了 Hololens 作为工业制造工具,面向选定的企业集团。价值 3,000 美元的商业套件附带专业版 Windows,具有额外的安全功能和软件以帮助应用程序开发。第二次迭代于 2019 年首次亮相,价格稍贵,但拥有更好的相机和镜头卡口,可实现更精确的操作,并提供更广泛的软件功能,包括工业应用。 

目前Hololens用户包括像肯沃斯,三得利和丰田,它使用耳机,以加快培养和汽车修理重量级人物,根据微软

苹果

如果你相信传言,苹果一直在释放的风口浪尖AR眼镜多年这家 iPhone 制造商于 2017 年在 iOS 11 中发布了ARKit,这是为 Apple 设备创建增强现实应用程序的开发者框架。 据科技网站The Information报道,Apple 在 2019 年举行了一次 1000 人的会议,讨论了 iPhone 上的 AR 和两个潜力未来的产品,N421 智能眼镜和 N301 VR 耳机分析师现在推测,苹果正准备在 2022 年及以后发布 AR 产品。   

阀门

Valve 的Index耳机可以说是市场上最强大的消费虚拟现实产品。高分辨率屏幕流畅,控制器在虚拟现实和游戏环境中提供无与伦比的控制。该索引还与 Value 的Steam视频游戏市场集成,这意味着该设备已经堆满了兼容的内容。

它也很贵而且很笨拙。完整的 Index VR 套件的价格接近 1,000 美元,要正常运行,耳机需要多条电缆和传感器。Valve 继续创新和试验沉浸式虚拟现实耳机。分析师预计,这家总部位于贝尔维尤的游戏公司将很快发布一款独立耳机,与 Facebook 的Oculus Quest 2展开竞争

魔法飞跃

尽管虚拟现实的想法部分受到科幻小说的启发,但 Big Tech 对 AR 和 VR 未来的现代愿景直接受到 Magic Leap 的启发。该公司成立于 2010 年,2014 年从谷歌和芯片制造商高通等公司筹集了超过 5 亿美元 2015 年,该公司发布了一段令人惊叹的视频,旨在展示该产品的技术。但是怀疑论者质疑这项技术,最终的产品遭到了抨击

最初的 Magic Leap 耳机是在设计和广告等创意协作行业销售的。

Magic Leap于 2018 年推出了一款精致的 AR 设备,筹集了更多资金,并计划在 2022 年初发布Magic Leap 2。该公司还计划瞄准国防、医疗保健和工业制造。

收起阅读 »

HashMap有何特别之处,为什么java面试从不缺席?

涉及知识点 看过java面试经验分享的小伙伴或者经历过准备过校招面试的小伙伴应该都曾经被Hashmap给支配过,即使是社招HashMap也仍然是高频考点,那么究竟为什么大家都喜欢问HashMap,其中包含了哪些知识点? 首先从生产的角度来说,HashMap是...
继续阅读 »

涉及知识点


看过java面试经验分享的小伙伴或者经历过准备过校招面试的小伙伴应该都曾经被Hashmap给支配过,即使是社招HashMap也仍然是高频考点,那么究竟为什么大家都喜欢问HashMap,其中包含了哪些知识点?



  • 首先从生产的角度来说,HashMap是我们在生产过程中最常用的集合之一,如果完全不懂它的原理,很难发挥出它的优势甚至会造成线上bug

  • 从数据结构和算法的角度,HashMap涉及到了数组,链表,红黑树,以及三者相互转化的过程,以及位运算等丰富的数据结构和算法内容。


所以HashMap就成为了面试的高频考点,这些过程都清楚,说明数据结构和算法的基础不会太差。


HashMap基本知识


JDK1.8之前数据结构是数组+链表
JDK1.8以后采用数组+链表+红黑树


image.png


其中,当数组元素超过64个且数组单个位置存储的链表元素个数超过8个时,会进化成红黑树,红黑树的引进时为了加快查找效率


同样,当数组单个位置存储的链表元素个数少于6个时,又会退化成链表


HashMap重要的类属性


其实JDK的源码都有非常详细的注释,但还是翻译一下吧,如下:



  • 初始容量大小为16


/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16


  • 最大容量为2^30


/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;


  • 默认的负载因子为0.75


负载因子之所以选择是基于时间和空间平衡的结果选择的参数,时间和空间的平衡是指既不浪费太多的空间,又不用频繁地进行扩容


/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;


  • 链表元素数量进化成红黑树的阈值为8,数组元素大于64时,链表元素超过8就会进化为红黑树


/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;

至于为什么选8,在之前有一段很长的注释里面有说明,可以看一下原文如下:


 * Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million

大概意思就是:如果 hashCode的分布离散良好的话,那么红黑树是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,注释中给我们展示了1-8长度的具体命中概率,当长度为8的时候,概率概率仅为0.00000006,这么小的概率,HashMap的红黑树转换几乎不会发生



  • 红黑树元素少于6个就退化成链表


至于为什么是6个不是7个是为了避免频繁在树与链表之间变换,如果是7,加一个元素就需要进化成红黑树,删一个元素就需要退化成链表


/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;


  • 链表树能进化成树时最小的数组长度,只有数组长度打到这个值链表才可能进化成树


/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;

重要的内部类


链表Node<K,V>


定义的链表Node类,next有木有很熟悉,单向链表
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;

Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}

public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }

public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}

public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}

public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}

红黑树TreeNode<K,V>


/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}

/**
* Returns root of tree containing this node.
*/
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}

HashMap的put方法


实际上调用的是putVal方法


/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

接下来看看putVal方法


/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

putVal的流程如下:


HashMap的put方法执行过程.png


HashMap的resize扩容方法(重要)


先翻译一下这个方法的官方注释:
初始化或者将原来的容量翻倍,如果为空,则分配初始容量,否则,进行容量翻倍,原来存在的元素要么留在原来的位置上,要么在新的数组中向后移动原来数组容量的大小


/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//数组原来不为空
if (oldCap > 0) {
// 超过最大值就不再扩充了,就只好随你碰撞去吧
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//初始化,申明HashMap的时候指定了初始容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//初始化,申明HashMap的时候没有指定初始容量
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;

//扩容之后的新数组
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

整个扩容的流程如下:


HashMap的resize过程.png


链表重新整理的过程


可能会将原来一个链表拆分为两个链表,判断当前链表元素是否需要移动到新的链表的依据是:
计算(e.hash & oldCap) == 0?
true,不需要移动到另一个链表,我们用头结点为loHead的链表将这些元素串起来
false,元素需要移动到另一个新的链表,我们用头结点为hiHead的链表将这些元素串起来
(tips:其实lo和hi是low和high的缩写,这里用的是两个双指针来进行链表的拆分整理)
以数组从容量为16扩容到32为例,位于下标为1的链表扩容之后如图所示:一部分链表仍然保留在下标为1的位置,另一部分则迁移至下标为1+16的位置。


HashMap扩容链表重新整理.png
在链表重新整理的过程中,同一个链表的元素的相对顺序不会被改变


红黑树重新整理的过程


看了一下TreeNode的继承关系,TreeNdoe也是继承Node的子类,红黑树重新整理的过程也和链表相似,因为它其实也维护了一个链表的结构
拆分可能会将原来一个红黑树拆分为两个链表,或者一个红黑树一个链表,判断当前链表元素是否需要移动到新的链表的依据是:
计算(e.hash & oldCap) == 0?
true,不需要移动,仍然在原来的位置,我们用头结点为loHead的链表将这些元素串起来,如果元素的个数不小于6,则继续维护成红黑树,否则为链表
false,元素需要移动到另一个新的位置,我们用头结点为hiHead的链表将这些元素串起来,如果元素的个数不小于6,则继续维护成红黑树,否则为链表


HashMap的TreeNode.png


HashMap线程安全性


HashMap中存在的线程安全问题:


1.HashMap在读取Hash槽首元素的时候读取的是工作内存中引用所指向的对象,并发情况下,其他线程修改的值并不能被及时读取到。


2.HashMap在插入新元素的时候,主要会进行两次判断:


2.1 第一次是根据键的hash判断当前hash槽是否被占用,如果没有就放入当前插入对象。并发情况下,如果A线程判断该槽未被占用,在执行写入操作时时间片耗尽。此时线程B也执行获取hash(恰巧和A线程的对象发生hash碰撞)判断该槽未被占用,继而直接插入该对象。然后A线程被CPU重新调度继续执行写入操作,就会将线程B的数据覆盖。(注:此处也有可见性问题)


2.2 第二次是同一个hash槽内,因为HashMap特性是保持key值唯一,所以会判断当前欲插入key是否存在,存在就会覆盖。与上文类似,并发情况下,如果线程A判断最后一个节点仍未发现重复key那么会把以当前对象构建节点挂在链表或者红黑树上,如果线程B在A判断操作和写操作之间,进行了判断和写操作,也会发生数据覆盖。


除此之外扩容也会发生类似的并发问题。还有size的问题,感觉其实高并发情况下这个size的准确性可以让步性能。


参考文章


【1】tech.meituan.com/2016/06/24/…

【2】blog.csdn.net/weixin_3143…


作者:ForeverKobe
链接:https://juejin.cn/post/7033392478753390629
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

为什么 JakeWharton 建议:App 只要用到一个 Activity ?

安卓开发大神级人物 JakeWharton 前不久在接受采访时提出一个颇具争议而又没有给出原因的建议:一个 App 只需要一个 Activity ,你可以使用 Fragments,只是别用 Fragments 回退栈。 针对这一言论,有关 JakeWharto...
继续阅读 »

安卓开发大神级人物 JakeWharton 前不久在接受采访时提出一个颇具争议而又没有给出原因的建议:一个 App 只需要一个 Activity ,你可以使用 Fragments,只是别用 Fragments 回退栈。


针对这一言论,有关 JakeWharton 建议的背后原因的一个提问迅速在 Reddit 国外网站的安卓开发频道引发热评。



众多安卓开发人员纷纷提出自己的见解。其中获赞最高的一条,甚至得到 JakeWharton 本人的亲自赞评。



我们来看看这条回答都提到了哪些内容,对 Activity 和 Fragment 之间的爱恨情仇有何独到的见解,凭什么能得到 JakeWharton 本尊的青睐有加。




因为 Activity 是一个程序入口。你可以将其视为 app 的一个 main 函数。站在用户的立场上,通常你进入 app 的方式可能包括以下几种:




  • launcher 桌面程序(main 函数入口);




  • 来自参数化 main 函数入口的通知栏,并且导航到 app 的指定位置;




  • 如果你做的是一个相机应用,那么需要处理图片请求的 intents;




  • 如果你做的是一个社交产品,那么需要处理 share 请求的 intents;




差不多类似这些场景。


但是,如果你真的不用分享和来自应用的 intents 的话,并且唯一的程序入口就是 launcher 桌面,别为每一个页面创建一个新的入口。这样做其实没有意义。为什么没有意义?因为这种场景下,进程死掉后 launcher 能够启动任何你应用中的 Activity 页面。


Fragments 是处理生命周期事件的视图控制器,并且非常不错。然而,Fragments 回退栈简直垃圾;回退栈变化监听器总是不正常地被调用( 1 次 transaction 三次调用?),并且不告诉你调用什么,而在恢复事务时也不知道哪些 fragments 是可用的。


你可以给事务添加 tag 标签,然后从栈中弹出操作,但是仅仅是一个 main -> Events -> Details(id=123) 的操作流程就相当繁琐了。


同样的,一旦你将一个 Fragment 放进回退栈中,我个人不知道它的生命周期开始做什么。我曾经遇到过一个后台中的 fragment 被调用四次 onCreateView() 方法,我甚至不知道究竟怎么了。而没有位于回退栈中的 Fragments 是可以被预见的。它们的动画支持有点古怪,但至少它们还能使用。


所以如果你想知道哪些 Fragments 是你能够操作的并且哪些 views 是你正在展示的并且能够在你自己的导航状态控制之中,那么你应该自己处理导航操作。把“应用逻辑”抽象化到一个 presenter(亦枫注:MVP 模式)中听起来来很棒,但是你是不是脱离了应用视图层里面的真实情况?



但是单一 activity 的优势是什么?



更简单的生命周期处理(例如,当 app 进入后台时,你只需要处理 onStop 方法),更少错误空间,和更多控制。同样的,你可以移动视图层外面的导航状态到 domain 层,或者至少到 presenter 中。不需要太多 view.navigateToDetail(songId) 之类的东西,你只需要在你的 presenter 或者 ViewModel 或者无论哪些时髦的用法中使用 backstack.goTo(SongKey.create(songId)) 就行。借助一个合适的库,当你到了 onResume 时它会自动将这些导航调用加入队列,并且不会致使 fragment 事务发生崩溃,非常得好。


尽管 Google 给出的案例也在用 commitAllowingStateLoss(),我有使用 commitNow() 的动画爱好。在我看来,单个 activity 能够看得见的好处就是,页面间共享 views 的能力,取代通过使用 <include 标签在 18 个布局文件重复视图。其他当然是更简单的导航操作。




以上便是深得 JakeWharton 大神心意的一条回答。话虽如此,但是系统 Fragment 存在的未解之谜或者说出乎你意料的坑实在太多。如果一定要在多 activity 部分 fragments 和单 activity 多 fragments 之间选择的话,我想不只是我,很多人还是毫不犹豫地选择前者。


更多讨论内容,参见:


http://www.reddit.com/r/androidde…


作者:亦枫
链接:https://juejin.cn/post/6844903521209286663
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

termux 安卓神器

今年春节在家的时候,手头没有电脑,但是想用电脑写下代码,于是乎我找到这一款termux神器,可以把安卓手机当作一台小型的服务器来使用。利用5年前已经淘汰的安卓手机,插上适配器,这样我就可以无休止的跑我的脚本了。termux 安装在termux官网上看到最新的版...
继续阅读 »

今年春节在家的时候,手头没有电脑,但是想用电脑写下代码,于是乎我找到这一款termux神器,可以把安卓手机当作一台小型的服务器来使用。利用5年前已经淘汰的安卓手机,插上适配器,这样我就可以无休止的跑我的脚本了。

termux 安装

在termux官网上看到最新的版本,必须要安装在Android 7以上的手机,我的魅族手机经过我的一番折腾只能升级到安卓6,不能安装最新的termux,还好旧的版本0.73支持Android 5以上。  安装包下载后,就是常规的apk安装了。

termux 环境配置

termux支持sshd,所有我们不用在旧手机上进行操作,我们可以在自己新安卓手机上通过ssh连接到旧手机上,前提是termux要启动sshd.

#安装openssh
pkg install openssh
#安装后,启动sshd
sshd
#设置用户密码
passwd
#登陆用户名
whoami
#查看局域网内ip
ifconfig|grep inet

我们在另外一台手机上安卓juicessh,一款安卓的ssh客户端,由于termux的sshd端口跟我们平时使用的22端是不一样的,所以在ssh到termux采用的是8022端口,通过刚才我们查看的用户名登陆,输入我们刚刚设置的密码,就能完成ssh手机登陆了。

现在我们就能愉快的采用termux进行学习了。

vim 练习

#安装vim
pkg install vim
vim hello.txt

 这就跟我们配置在服务器用vim一样,我们就可以练习下vim的快捷键

安装nodejs

#安装nodejs
pkg install nodejs
node -v
pkg install npm
npm -v

安装软件的时候,会有点慢,但是千万不要切换镜像,因为我们的版本比较旧,国内的源没有搬运旧的,所以切换后会导致安装软件出现各自问题,这是卸载安装5次的体验。清华源网站已经提示了镜像仅适用于 Android 7.0 (API 24) 及以上版本,旧版本系统使用本镜像可能导致程序错误。

安装好nodejs后,我们可以做更多有趣的事情,运行node脚本,node server服务器,个人网站等等,甚至可以做一些定时任务,比如定时发天气预告邮件等。


作者:chiv
链接:https://juejin.cn/post/6930531076607737870
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

能让你更早下班的Python垃圾回收机制

人生苦短,只谈风月,谈什么垃圾回收。能让你更早下班的Python垃圾回收机制_内存空间据说上图是某语言的垃圾回收机制。。。我们写过C语言、C++的朋友都知道,我们的C语言是没有垃圾回收这种说法的。手动分配、释放内存都需要我们的程序员自己完成。不管是“内存泄漏”...
继续阅读 »



人生苦短,只谈风月,谈什么垃圾回收。

能让你更早下班的Python垃圾回收机制_内存空间

能让你更早下班的Python垃圾回收机制_内存空间

据说上图是某语言的垃圾回收机制。。。

我们写过C语言、C++的朋友都知道,我们的C语言是没有垃圾回收这种说法的。手动分配、释放内存都需要我们的程序员自己完成。不管是“内存泄漏” 还是野指针都是让开发者非常头疼的问题。所以C语言开发这个讨论得最多的话题就是内存管理了。但是对于其他高级语言来说,例如Java、C#、Python等高级语言,已经具备了垃圾回收机制。这样可以屏蔽内存管理的复杂性,使开发者可以更好的关注核心的业务逻辑。

对我们的Python开发者来说,我们可以当甩手掌柜。不用操心它怎么回收程序运行过程中产生的垃圾。但是这毕竟是一门语言的内心功法,难道我们甘愿一辈子做一个API调参侠吗?

1.什么是垃圾?

当我们的Python解释器在执行到定义变量的语法时,会申请内存空间来存放变量的值,而内存的容量是有限的,这就涉及到变量值所占用内存空间的回收问题。

当一个对象或者说变量没有用了,就会被当做“垃圾“。那什么样的变量是没有用的呢?

a = 10000

当解释器执行到上面这里的时候,会划分一块内存来存储 10000 这个值。此时的 10000 是被变量 a 引用的

a = 30000

当我们修改这个变量的值时,又划分了一块内存来存 30000 这个值,此时变量a引用的值是30000。

这个时候,我们的 10000 已经没有变量引用它了,我们也可以说它变成了垃圾,但是他依旧占着刚才给他的内存。那我们的解释器,就要把这块内存地盘收回来。

2.内存泄露和内存溢出

上面我们了解了什么是程序运行过程中的“垃圾”,那如果,产生了垃圾,我们不去处理,会产生什么样的后果呢?试想一下,如果你家从不丢垃圾,产生的垃圾就堆在家里会怎么呢?

  1. 家里堆满垃圾,有个美女想当你对象,但是已经没有空间给她住了。

  2. 你还能住,但是家里的垃圾很占地方,而且很浪费空间,慢慢的,总有一天你的家里会堆满垃圾

上面的结果其实就是计算机里面让所有程序员都闻风丧胆的问题,内存溢出和内存泄露,轻则导致程序运行速度减慢,重则导致程序崩溃。

内存溢出:程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory

内存泄露:程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光

3.引用计数

前面我们提到过垃圾的产生的是因为,对象没有再被其他变量引用了。那么,我们的解释器究竟是怎么知道一个对象还有没有被引用的呢?

答案就是:引用计数。python内部通过引用计数机制来统计一个对象被引用的次数。当这个数变成0的时候,就说明这个对象没有被引用了。这个时候它就变成了“垃圾”。

这个引用计数又是何方神圣呢?让我们看看代码

text = "hello,world"

上面的一行代码做了哪些工作呢?

  • 创建字符串对象:它的值是hello,world

  • 开辟内存空间:在对象进行实例化的时候,解释器会为对象分配一段内存地址空间。把这个对象的结构体存储在这段内存地址空间中。

我们再来看看这个对象的结构体

typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;

熟悉c语言或者c++的朋友,看到这个应该特别熟悉,他就是结构体。这是因为我们Python官方的解释器是CPython,它底层调用了很多的c类库与接口。所以一些底层的数据是通过结构体进行存储的。看不懂的朋友也没有关系。

这里,我们只需要关注一个参数:ob_refcnt

这个参数非常神奇,它记录了这个对象的被变量引用的次数。所以上面 hello,world 这个对象的引用计数就是 1,因为现在只有text这个变量引用了它。

3.1 变量初始化赋值:

text = "hello,world"

能让你更早下班的Python垃圾回收机制_内存空间_02

3.2 变量引用传递:

new_text = text

能让你更早下班的Python垃圾回收机制_垃圾回收_03

3.3 删除第一个变量:

del text

能让你更早下班的Python垃圾回收机制_垃圾回收机制_04

3.4 删除第二个变量:

del new_text

能让你更早下班的Python垃圾回收机制_垃圾回收_05

此时 “hello,world” 对象的引用计数为:0,被当成了垃圾。下一步,就该被我们的垃圾回收器给收走了。

能让你更早下班的Python垃圾回收机制_python_06

4.引用计数如何变化

上面我们了解了什么是引用计数。那这个参数什么时候会发生变化呢?

4.1 引用计数加一的情况

  • 对象被创建

a = "hello,world"
  • 对象被别的变量引用(赋值给一个变量)

b = a
  • 对象被作为元素,放在容器中(比如被当作元素放在列表中)

list = []
list.append(a)
  • 对象作为参数传递给函数

func(a)

4.2 引用计数减一

  • 对象的引用变量被显示销毁

del a
  • 对象的引用变量赋值引用其他对象

a = "hello, Python"   # a的原来的引用对象:a = "hello,world"
  • 对象从容器中被移除,或者容器被销毁(例:对象从列表中被移除,或者列表被销毁)

del list
list.remove(a)
  • 一个引用离开了它的作用域

func():
  a = "hello,world"
  return

func() # 函数执行结束以后,函数作用域里面的局部变量a会被释放

4.3 查看对象的引用计数

如果要查看对象的引用计数,可以通过内置模块 sys 提供的 getrefcount 方法去查看。

import sys
a = "hello,world"
print(sys.getrefcount(a))

注意:当使用某个引用作为参数,传递给 getrefcount() 时,参数实际上创建了一个临时的引用。因此,getrefcount() 所得到的结果,会比期望的多 1

5.垃圾回收机制

其实Python的垃圾回收机制,我们前面已经说得差不多了。

Python通过引用计数的方法来说实现垃圾回收,当一个对象的引用计数为0的时候,就进行垃圾回收。但是如果只使用引用计数也是有点问题的。所以,python又引进了标记-清除和分代收集两种机制。

Python采用的是引用计数机制为主,标记-清除和分代收集两种机制为辅的策略。

前面的引用计数我们已经了解了,那这个标记-清除跟分代收集又是什么呢?

5.1 引用计数机制缺点

Python语言默认采用的垃圾收集机制是“引用计数法 ”,该算法最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。

引用计数法:每个对象维护一个 ob_refcnt 字段,用来记录该对象当前被引用的次数,每当新的引用指向该对象时,它的引用计数ob_refcnt加1,每当该对象的引用失效时计数ob_refcnt减1,一旦对象的引用计数为0,该对象立即被回收,对象占用的内存空间将被释放。

缺点:

  1. 需要额外的空间维护引用计数

  2. 无法解决循环引用问题

什么是循环引用问题?看看下面的例子

a = {"key":"a"}  # 字典对象a的引用计数:1
b = {"key":"b"} # 字典对象b的引用计数:1

a["b"] = b # 字典对象b的引用计数:2
b["a"] = a # 字典对象a的引用计数:2

del a # 字典对象a的引用计数:1
del b # 字典对象b的引用计数:1

看上面的例子,明明两个变量都删除了,但是这两个对象却没有得到释放。原因是他们的引用计数都没有减少到0。而我们垃圾回收机制只有当引用计数为0的时候才会释放对象。这是一个无法解决的致命问题。这两个对象始终不会被销毁,这样就会导致内存泄漏。

那怎么解决这个问题呢?这个时候 标记-清除 就排上了用场。标记清除可以处理这种循环引用的情况。

5.2 标记-清除策略

Python采用了标记-清除策略,解决容器对象可能产生的循环引用问题。

该策略在进行垃圾回收时分成了两步,分别是:

  • 标记阶段,遍历所有的对象,如果是可达的(reachable),也就是还有对象引用它,那么就标记该对象为可达;

  • 清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收

这里简单介绍一下标记-清除策略的流程

能让你更早下班的Python垃圾回收机制_内存空间_07

可达(活动)对象:从root集合节点有(通过链式引用)路径达到的对象节点

不可达(非活动)对象:从root集合节点没有(通过链式引用)路径到达的对象节点

流程:

  1. 首先,从root集合节点出发,沿着有向边遍历所有的对象节点

  2. 对每个对象分别标记可达对象还是不可达对象

  3. 再次遍历所有节点,对所有标记为不可达的对象进行垃圾回收、销毁。

标记-清除是一种周期性策略,相当于是一个定时任务,每隔一段时间进行一次扫描。
并且标记-清除工作时会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行。

5.3 分代回收策略

分代回收建立标记清除的基础之上,因为我们的标记-清除策略会将我们的程序阻塞。为了减少应用程序暂停的时间,Python 通过“分代回收”(Generational Collection)策略。以空间换时间的方法提高垃圾回收效率。

分代的垃圾收集技术是在上个世纪 80 年代初发展起来的一种垃圾收集机制。

简单来说就是:对象存在时间越长,越可能不是垃圾,应该越少去收集

Python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,Python 将内存分为了 3“代”,分别为年轻代(第 0 代)、中年代(第 1 代)、老年代(第 2 代)。

那什么时候会触发分代回收呢?

import gc

print(gc.get_threshold())
# (700, 10, 10)
# 上面这个是默认的回收策略的阈值

# 也可以自己设置回收策略的阈值
gc.set_threshold(500, 5, 5)
  • 700:表示当分配对象的个数达到700时,进行一次0代回收

  • 10:当进行10次0代回收以后触发一次1代回收

  • 10:当进行10次1代回收以后触发一次2代回收

能让你更早下班的Python垃圾回收机制_垃圾回收_08

5.4 gc模块

  • gc.get_count():获取当前自动执行垃圾回收的计数器,返回一个长度为3的列表

  • gc.get_threshold():获取gc模块中自动执行垃圾回收的频率,默认是(700, 10, 10)

  • gc.set_threshold(threshold0[,threshold1,threshold2]):设置自动执行垃圾回收的频率

  • gc.disable():python3默认开启gc机制,可以使用该方法手动关闭gc机制

  • gc.collect():手动调用垃圾回收机制回收垃圾

其实,既然我们选择了python,性能就不是最重要的了。我相信大部分的python工程师甚至都还没遇到过性能问题,因为现在的机器性能可以弥补。而对于内存管理与垃圾回收,python提供了甩手掌柜的方式让我们更关注业务层,这不是更加符合人生苦短,我用python的理念么。如果我还需要像C++那样小心翼翼的进行内存的管理,那我为什么还要用python呢?咱不就是图他的便利嘛。所以,放心去干吧。越早下班越好!

创作不易,且读且珍惜。如有错漏还请海涵并联系作者修改,内容有参考,如有侵权,请联系作者删除。如果文章对您有帮助,还请动动小手,您的支持是我最大的动力。


作者: 趣玩Python
来源:https://blog.51cto.com/u_14666251/4674779

收起阅读 »

跨域问题及常见解决方法

1.出现跨域问题是因为浏览器的同源策列限制,下面是MDN文档对浏览器同源策略的描述,简单来说就是:同源策略会阻止一个域的javascript脚本和另外一个域的内容进行交互。所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host...
继续阅读 »



1.出现跨域问题是因为浏览器的同源策列限制,下面是MDN文档对浏览器同源策略的描述,简单来说就是:

同源策略会阻止一个域的javascript脚本和另外一个域的内容进行交互。所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host)和端口号(port)

浏览器的同源策略

同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

同源的定义

如果两个 URL 的 protocol、port (en-US) (如果有指定的话)和 host 都相同的话,则这两个 URL 是同源。这个方案也被称为“协议/主机/端口元组”,或者直接是 “元组”。(“元组” 是指一组项目构成的整体,双重/三重/四重/五重/等的通用形式)。

2.四种常见解决跨域的方法:

一,CORS:

跨域资源共享,它允许浏览器向非同源服务器,发出XMLHttpRequest请求。它对一般请求和非一般请求的处理方式不同: 1、一般跨域请求(对服务器没有要求):只需服务器端设置Access-Control-Allow-Origin 2、非一般跨域请求(比如要请求时要携带cookie):前后端都需要设置。

一般跨域请求服务器设置代码: (1)Node.JS

const http = require('http');
const server = http.createServer();
const qs = require('querystring');

server.on('request', function(req, res) {
   var postData = '';
   // 数据块接收中
   req.addListener('data', function(chunk) {
       postData += chunk;
  });
   // 数据接收完毕
   req.addListener('end', function() {
       postData = qs.parse(postData);
       // 跨域后台设置
       res.writeHead(200, {
           'Access-Control-Allow-Credentials': 'true',     // 后端允许发送Cookie
           'Access-Control-Allow-Origin': 'http://www.example.com',    // 允许访问的域(协议+域名+端口)  
      });
       res.end(JSON.stringify(postData));
  });
});
server.listen('8080');
console.log('running at port 8080...');

复制代码

(2)PHP

<?php
header("Access-Control-Allow-Origin:*");
复制代码

如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。

前端请求携带cookie代码:

(1)原生JavaScript

const xhr = new XMLHttpRequest(); 
// 前端设置是否带cookie
xhr.withCredentials = true;
};
复制代码

(2)axios

axios.defaults.withCredentials = true
复制代码

二,JSONP

JSONP 只支持get请求,不支持post请求。 核心思想:网页通过添加一个<scriot>标签,向服务器请求 JSON 数据,服务器收到请求后,将数据放在一个指定名字的回调函数的参数位置传回来。

原生JavaScript代码:

<script src="http://example.php?callback=getData"></script>
// 向服务器发出请求,请求参数callback是下面定义的函数名字

// 处理服务器返回回调函数的数据
<script type="text/javascript">
   function getData(res)
  {
       console.log(res.data)
  }
</script>
复制代码

三,设置document.domain

因为浏览器是通过document.domain属性来检查两个页面是否同源,因此只要通过设置相同的document.domain,两个页面就可以共享Cookie(此方案仅限主域相同,子域不同的跨域应用场景。)

// 两个页面都设置
document.domain = 'test.com';
复制代码

四,跨文档通信 API:window.postMessage()

调用postMessage方法实现父窗口向子窗口发消息(子窗口同样可以通过该方法发送消息给父窗口)

var openWindow = window.open('http://test2.com', 'title');

// 父窗口向子窗口发消息(第一个参数代表发送的内容,第二个参数代表接收消息窗口的url)
openWindow.postMessage('Nice to meet you!', 'http://test2.com');
//调用message事件,监听对方发送的消息

// 监听 message 消息
window.addEventListener('message', function (e) {
 console.log(e.source); // e.source 发送消息的窗口
 console.log(e.origin); // e.origin 消息发向的网址
 console.log(e.data);   // e.data   发送的消息
},false);


作者:玩具大兵
来源:https://juejin.cn/post/7035562059152490526

收起阅读 »

TypeScript 原始类型、函数、接口、类、泛型 基础总结

原始数据类型原始数据类型包括:BooleanStringNumberNullundefined类型声明是TS非常重要的一个特点,通过类型声明可以指定TS中变量、参数、形参的类型。Boolean 类型let boolean: boolean = truebool...
继续阅读 »



原始数据类型

原始数据类型包括:

  • Boolean

  • String

  • Number

  • Null

  • undefined

类型声明是TS非常重要的一个特点,通过类型声明可以指定TS中变量、参数、形参的类型。

  • Boolean 类型

    let boolean: boolean = true
    boolean = false
    boolean = null
    // bollean = 123 报错不可以将数字 123 赋值给 boolean类型的变量
  • Number 类型

    //ES6 Number 类型 新增支持2进制和8进制
    let num: number = 123
    num = 0b1111
  • String 类型

    let str1: string = 'hello TS'
    let sre2: string = `模板字符串也支持使用 ${str1}`]
  • Null 和 Undefined

    let n: null = null
    let u: undefined = undefined
    n = undefined
    u = null
    // undefined 和 null 是所有类型的子类型 所以可以赋值给number类型的变量
    let num: number = 123
    num = undefined
    num = null

any 类型

any 表示队变量没有任何显示,编译器失去了对 TS 的检测功能与 JS 无异(不建议使用)。

let notSure: any = 4
// any类型可以随意赋值
notSure = `任意模板字符串`
notSure = true
notSure = null

// 当 notSure 为any 类型时,在any类型上访问任何属性和调用方法都是允许的, 很有可能出现错误
notSure.name // 现在调用name属性是允许的,但很明显我们定义的notSure没有name这个属性,下面的调用sayName方法也是如此
notSure.sayName()

array 类型

// 数组类型,可以指定数组的类型和使用数组的方法和属性
let arrOfNumbers: number[] = [1, 2, 3]
console.log(arrOfNumbers.length);

arrOfNumbers.push(4)

tuple 元组类型

// 元组类型  元组就是固定长度,类型的数组  
// 类型和长度必须一致
let u: [string, number] = ['12', 12]
// let U: [string, number] = ['12', 12, true]   报错信息为:不能将类型“[string, number, boolean]”分配给类型“[string, number]”。源具有 3 个元素,但目标仅允许 2 个。

// 也可以使用数组的方法,如下所示push一个值给元组u
u.push(33)

Interface 接口

  • 对对象的形状(shape)进行描述

  • Duck Typing(鸭子类型)

interface Person {
   // readonly id 表示只读属性的id不可以修改
   readonly id: number;
   name: string;
   age: number;
   // weight? 表示可选属性,可以选用也可以不选用
   weight?: number
}

let host: Person = {
   id: 1,
   name: 'host',
   age: 20,
   weight: 70
}

//host.id = 2 报错 提示信息为:无法分配到 "id" ,因为它是只读属性

function 函数类型

// 方式一:函数声明的写法   z 为可选参数 ,
function add1 (x: number, y: number, z?: number): number {
   if (typeof z === 'number') {
       return x + y + z
  } else {
       return x + y
  }
}
// 需要注意的是:可选参数必须置于所有必选参数之后,否则会报错

add1(1, 2, 3)

// 方式二:函数表达式
const add2 = (x: number, y: number, z?: number): number => {
   if (typeof z === 'number') {
       return x + y + z
  } else {
       return x + y
  }
}


// 使用interface接口 描述函数类型
interface ISum {
  (x: number, y: number, z?: number): number
}
let add3: ISum = add1

值的注意的是:可选参数必须置于所有必选参数之后,否则会报错,如下图展示的错误案例所示:·

image-20211120152558241

类型推论

当定义变量时没有指定类型,编译器会自动推论第一次赋的值为默认类型

let s = 'str'
// s = 12 本句将会报错,提示为:不能将类型“number”分配给类型“string”

联合类型

使用| 分隔可选类型

let StringOrNumber: string | number
StringOrNumber = 123
StringOrNumber = '111'

类型断言

使用 as 关键字进行类型断言

function getLength (rod: number | string): number {
   const str = rod as string
   //这里我们可以用 as 关键字,告诉typescript 编译器,你没法判断我的代码,但是我本人很清楚,这里我就把它看作是一个 string,你可以给他用 string 的方法。
   if (str.length) {
       return str.length
  } else {
       const num = rod as number
       return num.toString().length
  }
}

类型守卫

// 4.类型守卫 type guard     typescript 在不同的条件分支里面,智能的缩小了范围
function getLength2 (rod: number | string): number {
   if (typeof rod === 'string') {
       return rod.length
  } else {
       // else 里面的rod 会自动默认为number类型
       return rod.toString().length
       
  }
}

Class 类

面向对象编程的三大特点:

  • 封装(Encapsulation):将对数据的操作细节隐藏起来,只暴露对外的接口。外界调用端不需要(也不可能)知道细节,就能通过对外提供的接口来访问该对象,

  • 继承(Inheritance):子类继承父类,子类除了拥有父类的所有特性外,还有一些更具体的特性。

  • 多态(Polymorphism):由继承而产生了相关的不同的类,对同一个方法可以有不同的响应。

话不多少,看代码:

class Animal {
   readonly name: string
   constructor(name: string) {
       this.name = name
       console.log(this.run())
  }
   // private run ():私有的   protected run () 受保护的
   run () {
       return `${this.name} is running`
  }

}
const animal = new Animal('elephant')
// console.log(animal.name)
animal.run() //elephant is running

// 继承
class Dog extends Animal {
   age: number
   constructor(name, age) {
       super(name)
       console.log(this.name)
       this.age = age
  }
   bark () {
       console.log(`这只在叫的狗狗叫${this.name},它今年${this.age}岁了`)
  }
}
const dog = new Dog('旺财', 5)
dog.run() // 旺财 is running
dog.bark() // 这只在叫的狗狗叫旺财,它今年5岁了


// 多态
class Cat extends Animal {
   static catAge = 2

   constructor(name) {
       super(name)
       console.log(this.name) // 布丁
  }
   run () {
       return 'Meow,' + super.run()
  }
}
const cat = new Cat('布丁')
console.log(cat.run()) // Meow,布丁 is running
console.log(Cat.catAge) // 2

class中还提供了readonly关键字,readonly为只读属性,在调用的时候不能修改。如下所示:

image-20211124155826105

类成员修饰符

  • public修饰的属性或方法是公有的,可以在任何地方被访问到,默认所有的属性和方法都是public的。

  • private修饰的属性或方法是私有的,不能在声明它的类的外部访问。

    上述示例代码中,在父类Animalrun 方法身上加上private修饰符之后就会产生如下图的报错信息:

    image-20211124154129377

  • protected 修饰的属性或方法是受保护的,它和private类似,区别在于它在子类中也是可以访问的。

上述示例代码中,在父类Animalrun 方法身上加上protected修饰符之后就会产生如下图的报错信息:

image-20211124153823828

接口和类

类可以使用 implements来实现接口。

// interface可以用来抽象验证类的方法和方法
interface Person {
   Speak (trigger: boolean): void;
}
interface Teenagers {
   Young (sge: number): void
}
// 接口之间的继承
interface PersonAndTeenagers extends Teenagers {
   Speak (trigger: boolean): void;
}

// implements 实现接口
class Boy implements Person {
   Speak (mouth: boolean) { }
}

// class Girl implements Person, Teenagers 和 class Girl implements PersonAndTeenagers 作用相同
class Girl implements PersonAndTeenagers {
   Speak (mouth: boolean) { }
   Young (sge: number) { }
}

enum枚举

枚举成员会被赋值为从 0 开始递增的数字,同时也会对枚举值到枚举名进行反向映射:

enum Color {
   red = 'red',
   blue = 'blue',
   yellow = 'yellow',
   green = 'green'
}
// 常量枚举
const enum Color {
   red = 'red',
   blue = 'blue',
   yellow = 'yellow',
   green = 'green'
}
console.log(Color.red) // 0
// 反向映射
console.log(Color[0]) // red

const value = 'red'
if (value === Color.red) {
   console.log('Go Go Go ')
  • 常量枚举经过编译后形成的js文件如下:

image-20211124182628644

  • 非常量枚举经过编译器编译之后的js文件如下:

image-20211124182633413

Generics 泛型

泛型(Generics)是指在定义函数、接口或类的时候,不预先指定具体的类型,而在使用的时候再指定类型的一种特性。

  • 约束泛型

  • 类与泛型

  • 接口与泛

示例代码如下:

function echo (arg) {
   return arg
}

let result1 = echo(123) // 参数传递123后result1 的类型为any

// 泛型
function echo2<T> (arg: T): T {
   return arg
}
let result2 = echo2(123)  // 加上泛型之后 参数传递 123后result2的类型为number

function swap<T, U> (tuple: [T, U]): [U, T] {
   return [tuple[1], tuple[0]]
}
console.log(swap(['hero', 123]))//[ 123, 'hero' ]


// 约束泛型
interface IWithLength {
   length: number
}

function echoWithLength<T extends IWithLength> (arg: T): T {
   console.log(arg.length)
   return arg
}

const str = echoWithLength('123')
const obj = echoWithLength({ length: 3, name: 'Tom' })
const arr = echoWithLength([1, 2, 3, 4])


// 类与泛型
class Queue<T> {
   private data = []
   push (item: T) {
       return this.data.push(item)
  }
   pop (): T {
       return this.data.shift()
  }
}
const queue = new Queue<number>()

queue.push(1)
console.log(queue.pop().toFixed())// 1


// 接口与泛型
interface KeyPair<T, U> {
   key: T
   value: U
}
let kp1: KeyPair<string, number> = { key: 'str', value: 123 }
let kp2: KeyPair<number, string> = { key: 123, value: 'str' }
let arr2: Array<string> = ['1', '2'] // 使用 Array<string> 等价于 interface Array<T>

类型别名 type-alias

类型别名,就是给类型起一个别名,让它可以更方便的被重用。

let sum: (x: number, y: string) => number
const result1 = sum(1, '2')
// 将(x: number, y: string) => number类型取一个别名 为 PlusType
type PlusType = (x: number, y: string) => number
let sum2: PlusType
const result2 = sum2(2, '2')

type StrOrNum = string | number
let result3: StrOrNum = 123
result3 = '123'

字面量

let Name: 'name' = 'name'
// Name = '123' //报错信息:不能将类型“"123"”分配给类型“"name"”
let age: 19 = 19

type Directions = 'Up' | 'Down' | 'Left' | 'Right'
let up: Directions = 'Up'

交叉类型

// 交叉类型  使用 ‘&’ 符号进行类型的扩展
interface IName {
   name: string
}

type IPerson = IName & { age: number }
let person: IPerson = { name: 'Tom', age: 19 }

内置类型

  • 全局对象

// global objects 全局对象
const a: Array<string> = ['123', '456']
const time = new Date()
time.getTime()
const reg = /abc/ // 此时reg为RegExp类型
reg.test('abc')
  • build-in object 内置对象

    Math.pow(2, 2) //返回 2 的 2次幂。
    console.log(Math.pow(2, 2)) // 4
  • DOM and BOM

    // document 对象,返回的是一个 HTMLElement
    let body: HTMLElement = document.body
    // document 上面的query 方法,返回的是一个 nodeList 类型
    let allLis = document.querySelectorAll('li')

    //当然添加事件也是很重要的一部分,document 上面有 addEventListener 方法,注意这个回调函数,因为类型推断,这里面的 e 事件对象也自动获得了类型,这里是个 mouseEvent 类型,因为点击是一个鼠标事件,现在我们可以方便的使用 e 上面的方法和属性。
    document.addEventListener('click', (e) => {
       e.preventDefault()
    })
  • utility types实用类型

    interface IPerson2 {
       name: string
       age: number
    }
    let viking: IPerson2 = { name: 'viking', age: 20 }
    // partial,它可以把传入的类型都变成可选
    type IPartial = Partial<IPerson>
    let viking2: IPartial = {} // Partial 将IPerson 中的类型变成了可选类型 所以 viking2 可以等于一个空对象

    // Omit,它返回的类型可以忽略传入类型的某个属性
    type IOmit = Omit<IPerson2, 'name'> // 忽略name属性
    let viking3: IOmit = { age: 20 }

如果加上name属性将会报错:不能将类型“{ age: number; name: string; }”分配给类型“IOmit”。对象文字可以只指定已知属性,并且“name”不在类型“IOmit”中。


作者:不一213
来源:https://juejin.cn/post/7035563509882552334

收起阅读 »

神奇的交叉观察器 - IntersectionObserver

1. 背景网页开发时,不管是在移动端,还是PC端,都有个很重要的概念,叫做动态懒加载,适用于一些图片资源(或者数据)特别多的场景中,这个时候,我们常常需要了解某个元素是否进入了“视口”(viewport),即用户能不能看到它。 传统的实现方法是,监听到scro...
继续阅读 »

1. 背景

网页开发时,不管是在移动端,还是PC端,都有个很重要的概念,叫做动态懒加载,适用于一些图片资源(或者数据)特别多的场景中,这个时候,我们常常需要了解某个元素是否进入了“视口”(viewport),即用户能不能看到它。


传统的实现方法是,监听到scroll事件或者使用setInterval来判断,调用目标元素的getBoundingClientRect()方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于scroll事件触发频率高,计算量很大,如果不做防抖节流的话,很容易造成性能问题,而setInterval由于其有间歇期,也会出现体验问题。


所以在几年前,Chrome率先提供了一个新的API,就是IntersectionObserver,它可以用来自动监听元素是否进入了设备的可视区域之内,而不需要频繁的计算来做这个判断。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做”交叉观察器”。


2. 兼容性

由于这个api问世已经很多年了,所以对浏览器的支持性还是不错的,完全可以上生产环境,点击这里可以看看当前浏览器对于IntersectionObserver的支持性:


111111.png

3. 用法

API的调用非常简单:


const io = new IntersectionObserver(callback, option);

上面代码中,IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:



  • callback:可见性发现变化时的回调函数
  • option:配置对象(可选)。

构造函数的返回值是一个观察器实例。实例一共有4个方法:



  • observe:开始监听特定元素
  • unobserve:停止监听特定元素
  • disconnect:关闭监听工作
  • takeRecords:返回所有观察目标的对象数组

3.1 observe

该方法需要接收一个target参数,值是Element类型,用来指定被监听的目标元素


// 获取元素
const target = document.getElementById("dom");

// 开始观察
io.observe(target);

3.2 unobserve

该方法需要接收一个target参数,值是Element类型,用来指定停止监听的目标元素


// 获取元素
const target = document.getElementById("dom");

// 停止观察
io.unobserve(target);

3.3 disconnect

该方法不需要接收参数,用来关闭观察器


// 关闭观察器
io.disconnect();

3.4 takeRecords

该方法不需要接收参数,返回所有被观察的对象,返回值是一个数组


// 获取被观察元素
const observerList = io.takeRecords();

注意:

observe方法的参数是一个 DOM 节点,如果需要观察多个节点,就要多次调用这个方法:


// 开始观察多个元素
io.observe(domA);
io.observe(domB);
io.observe(domC);

4. callback 参数

目标元素的可见性变化时,就会调用观察器的回调函数callback


callback一般会触发两次。一次是目标元素刚刚进入视口,另一次是完全离开视口。


const io = new IntersectionObserver((changes, observer) => {
console.log(changes);
console.log(observer);
});

上面代码中,callback函数的参数接收两个参数changesobserver



  • changes:这是一个数组,每个成员都是一个被观察对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,那么changes数组里面就会打印出两个元素,如果只观察一个元素,我们打印changes[0]就能获取到被观察对象
  • observer: 这是一个对象,返回我们在实例中传入的第二个参数option(如果没传,则返回默认值)

5. IntersectionObserverEntry 对象

上面提到的changes数组中的每一项都是一个IntersectionObserverEntry 对象(下文简称io对象),对象提供目标元素的信息,一共有八个属性,我们打印这个对象:


// 创建实例
const io = new IntersectionObserver(changes => {
changes.forEach(change => {
console.log(change);
});
});

// 获取元素
const target = document.getElementById("dom");

// 开始监听
io.observe(target);

运行上面代码,并且改变dom的可见性,这时控制台可以看到一个对象:


555.png

每个属性的含义如下:



  • boundingClientRect:目标元素的矩形区域的信息
  • intersectionRatio:目标元素的可见比例,即intersectionRectboundingClientRect的比例,完全可见时为1,完全不可见时小于等于0
  • intersectionRect:目标元素与视口(或根元素)的交叉区域的信息
  • isIntersecting: 布尔值,目标元素与交集观察者的根节点是否相交
  • isVisible: 布尔值,目标元素是否可见(该属性还在试验阶段,不建议在生产环境中使用)
  • rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回null
  • target:被观察的目标元素,是一个 DOM 节点对象
  • time:可见性发生变化的时间,是一个高精度时间戳,单位为毫秒

6. 应用


  1. 预加载(滚动加载,翻页加载,无限加载)
  2. 懒加载(后加载、惰性加载)
  3. 其它

7. 注意点

IntersectionObserver API 是异步的,不随着目标元素的滚动同步触发。


规格写明,IntersectionObserver的实现,应该采用requestIdleCallback(),即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。


8. 参考链接


作者:三年没洗澡
来源:https://juejin.cn/post/7035490578015977480

收起阅读 »

美团外卖iOS多端复用的推动、支撑与思考

iOS
前言美团外卖2013年11月开始起步,随后高速发展,不断刷新多项行业记录。截止至2018年5月19日,日订单量峰值已超过2000万,是全球规模最大的外卖平台。业务的快速发展对技术支撑提出了更高的要求:为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运...
继续阅读 »



前言

美团外卖2013年11月开始起步,随后高速发展,不断刷新多项行业记录。截止至2018年5月19日,日订单量峰值已超过2000万,是全球规模最大的外卖平台。业务的快速发展对技术支撑提出了更高的要求:为线上用户提供高稳定的服务体验,保障全链路业务和系统高可用运行的同时,要提升多入口业务的研发速度,推进App系统架构的合理演化,进一步提升跨部门跨地域团队之间的协作效率。

而另一方面随着用户数与订单数的高速增长,美团外卖逐渐有了流量平台的特征,兄弟业务纷纷尝试接入美团外卖进行推广和发布,期望提供统一标准化服务平台。因此,基础能力标准化,推进多端复用,同时输出成熟稳定的技术服务平台,一直是我们技术团队追求的核心目标。

多端复用的端

这里的“端”有两层意思:

  • 其一是相同业务的多入口

美团外卖在iOS下的业务入口有三个,『美团外卖』App、『美团』App的外卖频道、『大众点评』App的外卖频道。

值得一提的是:由于用户画像与产品策略差异,『大众点评』外卖频道与『美团』外卖频道和『美团外卖』虽经历技术栈融合,但业务形态区别较大,暂不考虑上层业务的复用,故这篇文章主要介绍美团系两大入口的复用。

在2015年外卖C端合并之前,美团系的两大入口由两个不同的团队研发,虽然用户感知的交互界面几乎相同,但功能实现层面的代码风格和技术栈都存在较大差异,同一需求需要在两端重复开发显然不合理。所以,我们的目标是相同功能,只需要写一次代码,做一次估时,其他端只需做少量的适配工作。

  • 其二是指平台上各个业务线

外卖不同兄弟业务线都依赖外卖基础业务,包括但不限于:地图定位、登录绑定、网络通道、异常处理、工具UI等。考虑到标准化的范畴,这些基础能力也是需要多端复用的。

img

图1 美团外卖的多端复用的目标

关于组件化

提到多端复用,不免与组件化产生联系,可以说组件化是多端复用的必要条件之一。大多数公司口中的“组件化”仅仅做到代码分库,使用Cocoapods的Podfile来管理,再在主工程把各个子库的版本号聚合起来。但是能设计一套合理的分层架构,理清依赖关系,并有一整套工具链支撑组件发版与集成的相对较少。否则组件化只会导致包体积增大,开发效率变慢,依赖关系复杂等副作用。

整体思路

A. 多端复用概念图

img

图2 多端复用概念图

多端复用的目标形态其实很好理解,就是将原有主工程中的代码抽出独立组件(Pods),然后各自工程使用Podfile依赖所需的独立组件,独立组件再通过podspec间接依赖其他独立组件。

B. 准备工作

确认多端所依赖的基层库是一致的,这里的基层库包括开源库与公司内的技术栈。

iOS中常用开源库(网络、图片、布局)每个功能基本都有一个库业界垄断,这一点是iOS相对于Android的优势。公司内也存在一些对开源库二次开发或自行研发的基础库,即技术栈。不同的大组之间技术栈可能存在一定差异。如需要复用的端之间存在差异,则需要重构使得技术栈统一。(这里建议重构,不建议适配,因为如果做的不够彻底,后续很大可能需要填坑。)

就美团而言,美团平台与点评平台作为公司两大App,历史积淀厚重。自2015年底合并以来,为了共建和沉淀公共服务,减少重复造轮子,提升研发效率,对上层业务方提供统一标准的高稳定基础能力,两大平台的底层技术栈也在不断融合。而美团外卖作为较早实践独立App,同时也是依托于两大平台App的大业务方,在外卖C端合并后的1年内,我们也做了大量底层技术栈统一的必要工作。

C. 方案选型

在演进式设计与计划式设计中的抉择。

演进式设计指随着系统的开发而做设计变更,而计划式设计是指在开发之前完全指定系统架构的设计。演进的设计,同样需要遵循架构设计的基本准则,它与计划的设计唯一的区别是设计的目标。演进的设计提倡满足客户现有的需求;而计划的设计则需要考虑未来的功能扩展。演进的设计推崇尽快地实现,追求快速确定解决方案,快速编码以及快速实现;而计划的设计则需要考虑计划的周密性,架构的完整性并保证开发过程的有条不紊。

美团外卖iOS客户端,在多端复用的立项初期面临着多个关键点:频道入口与独立应用的复用,外卖平台的搭建,兄弟业务的接入,点评外卖的协作,以及架构迁移不影响现有业务的开发等等,因此权衡后我们使用“演进式架构为主,计划式架构为辅”的设计方案。不强求历史代码一下达到终极完美架构,而是循序渐进一步一个脚印,满足现有需求的同时并保留一定的扩展性。

演进式架构推动复用

术语解释

  • Waimai:特指『美团外卖』App,泛指那些独立App形式的业务入口,一般为project。

  • Channel:特指『美团』App中的外卖频道,泛指那些以频道或者Tab形式集成在主App内的业务入口,一般为Pods。

  • Special:指将Waimai中的业务代码与原有工程分离出来,让业务代码成为一个Pods的形态。

  • 下沉:即下沉到下层,这里的“下层”指架构的基层,一般为平台层或通用层。“下沉”指将不同上层库中的代码统一并移动到下层的基层库中。

在这里先贴出动态的架构演进过程,让大家有一个宏观的概念,后续再对不同节点的经历做进一步描述。

图3 演进式架构动态图

原始复用架构

如图4所示,在过去一两年,因为技术栈等原因我们只能采用比较保守的代码复用方案。将独立业务或工具类代码沉淀为一个个“Kit”,也就是粒度较小的组件。此时分层的概念还比较模糊,并且以往的工程因历史包袱导致耦合严重、逻辑复杂,在将UGC业务剥离后发现其他的业务代码无法轻易的抽出。(此时的代码复用率只有2.4%。)

鉴于之前的准备工作已经完成,多端基础库已经一致,于是我们不再采取保守策略,丰富了一些组件化通信、解耦与过渡的手段,在分层架构上开始发力。

img

图4 原始复用架构

业务复用探索

在技术栈已统一,基础层已对齐的背景下,我们挑选外卖核心业务之一的Store(即商家容器)开始了在业务复用上的探索。如图5所示,大致可以理解为“二合一,一分三”的思路,我们从代码风格和开发思路上对两边的Store业务进行对齐,在此过程中顺势将业务类与技术(功能)类的代码分离,一些通用Domain也随之分离。随着一个个组件的拆分,我们的整体复用度有明显提升,但开发效率却意外的受到了影响。多库开发在版本的发布与集成中增加了很多人工操作:依赖冲突、lock文件冲突等问题都阻碍了我们的开发效率进一步提升,而这就是之前“关于组件化”中提到的副作用。

于是我们将自动发版与自动集成提上了日程。自动集成是将“组件开发完毕到功能合入工程主体打出测试包”之间的一系列操作自动化完成。在这之前必须完成一些前期铺垫工作——壳工程分离。img

图5 商家容器下沉时期

壳工程分离

如图6所示,壳工程顾名思义就是将原来的project中的代码全部拆出去,得到一个空壳,仅仅保留一些工程配置选项和依赖库管理文件。

为什么说壳工程是自动集成的必要条件之一?

因为自动集成涉及版本号自增,需要机器修改工程配置类文件。如果在创建二进制的过程中有新业务PR合入,会造成commit树分叉大概率产生冲突导致集成失败。抽出壳工程之后,我们的壳只关心配置选项修改(很少),与依赖版本号的变化。业务代码的正常PR流程转移到了各自的业务组件git中,以此来杜绝人工与机器的冲突。

img

图6 壳工程分离

壳工程分离的意义主要有如下几点:

  • 让职能更加明确,之前的综合层身兼数职过于繁重。

  • 为自动集成铺路,避免业务PR与机器冲突。

  • 提升效率,后续Pods往Pods移动代码比proj往Pods移动代码更快。

  • 『美团外卖』向『美团』开发环境靠齐,降低适配成本。

img

图7 壳工程分离阶段图

图7的第一张图到第二张图就是上文提到的壳工程分离,将“Waimai”所有的业务代码打包抽出,移动到过渡仓库Special,让原先的“Waimai”成为壳。

第二张图到第三张图是Pods库的内部消化。

前一阶段相当于简单粗暴的物理代码移动,后一阶段是对Pods内整块代码的梳理与分库。

内部消化对齐

在前文“多端复用概念图”的部分我们提到过,所谓的复用是让多端的project以Pods的方式接入统一的代码。我们兼容考虑保留一端代码完整性,降低回接成本,决定分Subpods使用阶段性合入达到平滑迁移。

img

图8 代码下沉方案

图8描述了多端相同模块内的代码具体是如何统一的。此时因为已经完成了壳工程分离,所以业务代码都在“Special”这样的过渡仓库中。

“Special”和“Channel”两端的模块统一大致可分为三步:平移 → 下沉 → 回接。(前提是此模块的业务上已经确定是完全一致。)

平移阶段是保留其中一端“Special”代码的完整性,以自上而下的平移方式将代码文件拷贝到另一端“Channel”中。此时前者不受任何影响,后者的代码因为新文件拷贝和原有代码存在重复。此时将旧文件重命名,并深度优先遍历新文件的依赖关系补齐文件,最终使得编译通过。然后将旧文件中的部分差异代码加到新文件中做好一定的差异化管理,最后删除旧文件。

下沉阶段是将“Channel”处理后的代码解耦并独立出来,移动到下层的Pods或下层的SubPods。此时这里的代码是既支持“Special”也支持“Channel”的。

回接阶段是让“Special”以Pods依赖的形式引用之前下沉的模块,引用后删除平移前的代码文件。(如果是在版本的间隙完成固然最好,否则需要考虑平移前的代码文件在这段时间的diff。)

实际操作中很难在有限时间内处理完一个完整的模块(例如订单模块)下沉到Pods再回接。于是选择将大模块分成一个个子模块,这些子模块平滑的下沉到SubPods,然后“Special”也只引用这个统一后的SubPods,待一个模块完全下沉完毕再拆出独立的Pods。

再总结下大量代码下沉时如何保证风险可控:

  • 联合PM,先进行业务梳理,特殊差异要标注出来。

  • 使用OClint的提前扫描依赖,做到心中有数,精准估时。

  • 以“Special”的代码风格为基准,“Channel”在对齐时仅做加法不做减法。

  • “Channel”对齐工作不影响“Special”,并且回接时工作量很小。

  • 分迭代包,QA资源提前协调。

中间件层级压平

经过前面的“内部消化”,Channel和Special中的过渡代码逐渐被分发到合适的组件,如图9所示,Special只剩下AppOnly,Channel也只剩下ChannelOnly。于是Special消亡,Channel变成打包工程。

AppOnly和ChannelOnly 与其他业务组件层级压平。上层只留下两个打包工程。

img

图9 中间件层级压平

平台层建设

如图10所示,下层是外卖基础库,WaimaiKit包含众多细分后的平台能力,Domain为通用模型,XunfeiKit为对智能语音二次开发,CTKit为对CoreText渲染框架的二次开发。

针对平台适配层而言,在差异化收敛与依赖关系梳理方面发挥重要角色,这两点在下问的“衍生问题解决中”会有详细解释。

外卖基础库加上平台适配层,整体构成了我们的外卖平台层(这是逻辑结构不是物理结构),提供了60余项通用能力,支持无差异调用。

img

图10 外卖平台层的建设

多端通用架构

此时我们把基层组件与开源组件梳理并补充上,达到多端通用架构,到这里可以说真正达到了多端复用的目标。

img

图11 多端通用架构完成

由上层不同的打包工程来控制实际需要的组件。除去两个打包工程和两个Only组件,下面的组件都已达到多端复用。对比下“Waimai”与“Channel”的业务架构图中两个黑色圆圈的部分。

img

图12 “Waimai”的业务架构

img

图13 “Channel”的业务架构

衍生问题解决

差异问题

A.需求本身的差异

三种解决策略:

  • 对于文案、数值、等一两行代码的差异我们使用 运行时宏(动态获取proj-identifier)或预编译宏(custome define)直接在方法中进行if else判断。

  • 对于方法实现的不同 使用Glue(胶水层),protocol提供相同的方法声明,用来给外部调用,在不同的载体中写不同的方法实现。

  • 对于较大差异例如两边WebView容器不一样,我们建多个文件采用文件级预编译,可预编译常规.m文件或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)

进一步优化策略:

用上述三种策略虽然完成差异化管理,但差异代码散落在不同组件内难以收敛,不便于管理。有了平台适配层之后,我们将差异化判断收敛到适配层内部,对上层提供无差异调用。组件开发者在开发中不用考虑宿主差异,直接调用用通用接口。差异的判断或者后续优化在接口内部处理外部不感知。

图14给出了一个平台适配层提供通用接口修改后的例子。

img

图14 平台适配层接口示例

B.多端节奏差异

实际场景中除了需求的差异还有可能出现多端进版节奏的差异,这类差异问题我们使用分支管理模型解决。

前提条件既然要多端复用了,那需求的大方向还是会希望多端统一。一般较多的场景是:多端中A端功能最少,B端功能基本算是是A端的超集。(没有绝对的超集,A端也会有较少的差异点。)在外卖的业务中,“Channel”就是这个功能较少的一端,“Waimai”基本是“Channel”的超集。

两端的差异大致分为了这5大类9小类:

  1. 需求两端相同(1.1、提测上线时间基本相同;1.2、“Waimai”比“Channel”早3天提测 ;1.3、“Waimai”比“Channel”晚3天提测)。

  2. 需求“Waimai”先进版,“Channel”下一版进 (2.1、频道下一版就上;2.2、频道下两版本后再上)。

  3. 需求“Waimai”先进版,“Channel”不需要。

  4. 需求“Channel”先进版,“Waimai”下一版进(4.1、需要改动通用部分;4.2、只改动“ChannelOnly”的部分)。

  5. 需求“Channel”先进版,“Waimai”不需要(只改动“ChannelOnly”的部分)。

img

图15 最复杂场景下的分支模型

也不用过多纠结,图15是最复杂的场景,实际场合中很难遇到,目前的我们的业务只遇到1和2两个大类,最多2条线。

编译问题

以往的开发方式初次全量编译5分钟左右,之后就是差量编译很快。但是抽成组件后,随着部分子库版本的切换间接的增加了pod install的次数,此时高频率的3分钟、5分钟会让人难以接受。

于是在这个节点我们采用了全二进制依赖的方式,目标是在日常开发中直接引用编译后的产物减少编译时间。

img

图16 使用二进制的依赖方式

如图所示三个.a就是三个subPods,分了三种Configuration:

  1. debug/ 下是 deubg 设置编译的 x64 armv7 arm64。

  2. release/ 下是 release 设置编译的 armv7 arm64。

  3. dailybuild/ 下是 release + TEST=1编译的 armv7 arm64。

  4. 默认(在文件夹外的.a)是 debug x64 + release armv7 + release arm64。

这里有一个问题需要解决,即引用二进制带来的弊端,显而易见的就是将编译期的问题带到了运行期。某个宏修改了,但是编译完的二进制代码不感知这种改动,并且依赖版本不匹配的话,原本的方法缺失编译错误,就会带到运行期发生崩溃。解决此类问题的方法也很简单,就是在所有的打包工程中都配置了打包自动切换源码。二进制仅仅用来在开发中获得更高的效率,一旦打提测包或者发布包都会使用全源码重新编译一遍。关于切源码与切二进制是由环境变量控制拉取不同的podspec源。

并且在开发中我们支持源码与二进制的混合开发模式,我们给某个binary_pod修饰的依赖库加上标签,或者使用.patch文件,控制特定的库拉源码。一般情况下,开发者将与自己当前需求相关联的库拉源码便于Debug,不关联的库拉二进制跳过编译。

依赖问题

如图17所示,外卖有多个业务组件,公司也有很多基础Kit,不同业务组件或多或少会依赖几个Kit,所以极易形成网状依赖的局面。而且依赖的版本号可能不一致,易出现依赖冲突,一旦遇到依赖冲突需要对某一组件进行修改再重新发版来解决,很影响效率。解决方式是使用平台适配层来统一维护一套依赖库版本号,上层业务组件仅仅关心平台适配层的版本。

img

图17 平台适配层统一维护依赖

当然为了避免引入平台适配层而增加过多无用依赖的问题,我们将一些依赖较多且使用频度不高的Kit抽出subPods,支持可选的方式引入,例如IM组件。

再者就是pod install 时依赖分析慢的问题。对于壳工程而言,这是所有依赖库汇聚的地方,依赖关系写法若不科学极易在analyzing dependency中耗费大量时间。Cocoapods的依赖分析用的是Molinillo算法,链接中介绍了这个算法的实现方式,是一个具有前向检察的回溯算法。这个算法本身是没有问题的,依赖层级深只要依赖写的合理也可以达到秒开。但是如果对依赖树叶子节点的版本号控制不够严密,或中间出现了循环依赖的情况,会导致回溯算法重复执行了很多压栈和出栈操作耗费时间。美团针对此类问题的做法是维护一套“去依赖的podspec源”,这个源中的dependency节点被清空了(下图中间)。实际的所需依赖的全集在壳工程Podfile里平铺,统一维护。这么做的好处是将之前的树状依赖(下图左)压平成一层(下图右)。

img

图18 依赖数的压平

效率问题

前面我们提到了自动集成,这里展示下具体的使用方式。美团发布工程组自行研发了一套HyperLoop发版集成平台。当某个组件在创建二进制之前可自行选择集成的目标,如果多端复用了,那只需要在发版创建二进制的同时勾选多个集成的目标。发版后会自行进行一系列检查与测试,最终将代码合入主工程(修改对应壳工程的依赖版本号)。

img

图19 HyperLoop自动发版自动集成

img

图20 主工程commit message的变化

以上是“Waimai”的commit对比图。第一张图是以往的开发方式,能看出工程配置的commit与业务的commit交错堆砌。第二张图是进行壳工程分离后的commit,能看出每条message都是改了某个依赖库的版本号。第三张图是使用自动集成后的commit,能看出每条message都是画风统一且机器串行提交的。

这里又衍生出另一个问题,当我们用壳工程引Pods的方式替代了project集中式开发之后,我们的代码修改散落到了不同的组件库内。想看下主工程6.5.0版本和6.4.0版本的diff时只能看到所有依赖库版本号的diff,想看commit和code diff时必须挨个去组件库查看,在三轮提测期间这样类似的操作每天都会重复多次,很不效率。

于是我们开发了atomic diff的工具,主要原理是调git stash的接口得到版本号diff,再通过版本号和对应的仓库地址深度遍历commit,再深度遍历commit对应的文件,最后汇总,得到整体的代码diff。

img

图21 atomic diff汇总后的commit message

整套工具链对多端复用的支撑

上文中已经提到了一些自动化工具,这里整理下我们工具链的全景图。

img

图22 整套工具链

  1. 在准备阶段,我们会用OClint工具对compile_command.json文件进行处理,对将要修改的组件提前扫描依赖。

  2. 在依赖库拉取时,我们有binary_pod.rb脚本里通过对源的控制达到二进制与去依赖的效果,美团发布工程组维护了一套ios-re-sankuai.com的源用于存储remove dependency的podspec.json文件。

  3. 在依赖同步时,会通过sync_podfile定时同步主工程最新Podfile文件,来对依赖库全集的版本号进行维护。

  4. 在开发阶段,我们使用Podfile.patch工具一键对二进制/源码、远端/本地代码进行切换。

  5. 在引用本地代码开发时,子库的版本号我们不太关心,只关心主工程的版本号,我们使用beforePod和AfterPod脚本进行依赖过滤以防止依赖冲突。

  6. 在代码提交时,我们使用git squash对多条相同message的commit进行挤压。

  7. 在创建PR时,以往需要一些网页端手动操作,填写大量Reviewers,现在我们使用MTPR工具一键完成,或者根据个人喜好使用Chrome插件。

  8. 在功能合入master之前,会有一些jenkins的job进行检测。

  9. 在发版阶段,使用Hyperloop系统,一键发版操作简便。

  10. 在发版之后,可选择自动集成和联合集成的方式来打包,打包产物会自动上传到美团的“抢鲜”内测平台。

  11. 在问题跟踪时,如果需要查看主工程各个版本号间的commit message和code diff,我们有atomic diff工具深度遍历各个仓库并汇总结果。

感想总结

  • 多端复用之后对PM-RD-QA都有较大的变化,我们代码复用率由最初的2.4%*达到了*84.1%,让更多的PM投入到了新需求的吞吐中,但研发效率提升增大了QA的工作量。一个大的尝试需要RD不断与PM和QA保持沟通,选择三方都能接受的最优方案。

  • 分清主次关系,技术架构等最终是为了支撑业务,如果一个架构设计的美如画天衣无缝,但是落实到自己的业务中确不能发挥理想效果,或引来抱怨一片,那这就是个失败的设计。并且在实际开发中技术类代码修改尽量选择版本间隙合入,如果与业务开发的同学产生冲突时,都要给业务同学让路,不能影响原本的版本迭代速度。

  • 时刻对 “不合理” 和 “重复劳动”保持敏感。新增一个埋点常量要去改一下平台再发个版是否成本太大?一处订单状态的需求为什么要修改首页的Kit?实际开发中遇到别扭的地方多增加一些思考而不是硬着头皮过去,并且手动重复两次以上的操作就要思考有没有自动化的替代方案。

  • 一旦决定要做,在一些关键节点决不能手软。例如某个节点为了不Block别人,加班不可避免。在大量代码改动时也不用过于紧张,有提前预估,有Case自测,还有QA的三轮回归来保障,保持专注,放手去做就好。

作者简介

尚先,美团资深工程师。2015年加入美团,目前作为美团外卖iOS端平台化虚拟小组组长,主要负责业务架构、持续集成和工程化相关工作,致力于提升研发效率与协作效率。


作者:美团技术团队
来源:https://juejin.cn/post/6844903629753679886

收起阅读 »

女儿拿着小天才电话手表问我App启动流程(下)

接 女儿拿着小天才电话手表问我App启动流程(上) 第四关:ActivityThread闪亮登场刚才说到由Zygote进行fork进程,并返回新进程的pid。其实这过程中也实例化ActivityThread对象。一起看看是怎么实现的: //RuntimeIni...
继续阅读 »

女儿拿着小天才电话手表问我App启动流程(上)


第四关:ActivityThread闪亮登场

刚才说到由Zygote进行fork进程,并返回新进程的pid。其实这过程中也实例化ActivityThread对象。一起看看是怎么实现的:


//RuntimeInit.java
protected static Runnable findStaticMain(String className, String[] argv,
ClassLoader classLoader) {
Class<?> cl;

try {
cl = Class.forName(className, true, classLoader);
} catch (ClassNotFoundException ex) {
throw new RuntimeException(
"Missing class when invoking static main " + className,
ex);
}

Method m;
try {
m = cl.getMethod("main", new Class[] { String[].class });
} catch (NoSuchMethodException ex) {
throw new RuntimeException(
"Missing static main on " + className, ex);
} catch (SecurityException ex) {
throw new RuntimeException(
"Problem getting static main on " + className, ex);
}
//...
return new MethodAndArgsCaller(m, argv);
}

原来是反射!通过反射调用了ActivityThread 的 main 方法。ActivityThread大家应该都很熟悉了,代表了Android的主线程,而main方法也是app的主入口。这不对上了!新建进程的时候就调用了,可不是主入口嘛。来看看这个主入口。


public static void main(String[] args) {
//...
Looper.prepareMainLooper();

ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);

//...

if (false) {
Looper.myLooper().setMessageLogging(new
LogPrinter(Log.DEBUG, "ActivityThread"));
}
//...
Looper.loop();

throw new RuntimeException("Main thread loop unexpectedly exited");
}

main方法主要创建了ActivityThread,创建了主线程的Looper对象,并开始loop循环。除了这些,还要告诉AMS,我醒啦,进程创建好了!也就是上述代码中的attach方法,最后会转到AMSattachApplicationLocked方法,一起看看这个方法干了啥:


//ActivitymanagerService.java
private final boolean attachApplicationLocked(IApplicationThread thread,
int pid, int callingUid, long startSeq) {
//...
ProcessRecord app;
//...
thread.bindApplication(processName, appInfo, providers, null, profilerInfo,
null, null, null, testMode,
mBinderTransactionTrackingEnabled, enableTrackAllocation,
isRestrictedBackupMode || !normalMode, app.isPersistent(),
new Configuration(app.getWindowProcessController().getConfiguration()),
app.compat, getCommonServicesLocked(app.isolated),
mCoreSettingsObserver.getCoreSettingsLocked(),
buildSerial, autofillOptions, contentCaptureOptions);
//...
app.makeActive(thread, mProcessStats);

//...
// See if the top visible activity is waiting to run in this process...
if (normalMode) {
try {
didSomething = mAtmInternal.attachApplication(app.getWindowProcessController());
} catch (Exception e) {
Slog.wtf(TAG, "Exception thrown launching activities in " + app, e);
badApp = true;
}
}
//...
}

//ProcessRecord.java
public void makeActive(IApplicationThread _thread, ProcessStatsService tracker) {
//...
thread = _thread;
mWindowProcessController.setThread(thread);
}

这里主要做了三件事:



  • bindApplication方法,主要用来启动Application。
  • makeActive方法,设定WindowProcessController里面的线程,也就是上文中说过判断进程是否存在所用到的。
  • attachApplication方法,启动根Activity。

第五关:创建Application

接着上面看,按照我们所熟知的,应用启动后,应该就是启动Applicaiton,启动Activity。看看是不是怎么回事:


    //ActivityThread#ApplicationThread
public final void bindApplication(String processName, ApplicationInfo appInfo,
List<ProviderInfo> providers, ComponentName instrumentationName,
ProfilerInfo profilerInfo, Bundle instrumentationArgs,
IInstrumentationWatcher instrumentationWatcher,
IUiAutomationConnection instrumentationUiConnection, int debugMode,
boolean enableBinderTracking, boolean trackAllocation,
boolean isRestrictedBackupMode, boolean persistent, Configuration config,
CompatibilityInfo compatInfo, Map services, Bundle coreSettings,
String buildSerial, AutofillOptions autofillOptions,
ContentCaptureOptions contentCaptureOptions) {
AppBindData data = new AppBindData();
data.processName = processName;
data.appInfo = appInfo;
data.providers = providers;
data.instrumentationName = instrumentationName;
data.instrumentationArgs = instrumentationArgs;
data.instrumentationWatcher = instrumentationWatcher;
data.instrumentationUiAutomationConnection = instrumentationUiConnection;
data.debugMode = debugMode;
data.enableBinderTracking = enableBinderTracking;
data.trackAllocation = trackAllocation;
data.restrictedBackupMode = isRestrictedBackupMode;
data.persistent = persistent;
data.config = config;
data.compatInfo = compatInfo;
data.initProfilerInfo = profilerInfo;
data.buildSerial = buildSerial;
data.autofillOptions = autofillOptions;
data.contentCaptureOptions = contentCaptureOptions;
sendMessage(H.BIND_APPLICATION, data);
}

public void handleMessage(Message msg) {
if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
switch (msg.what) {
case BIND_APPLICATION:
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "bindApplication");
AppBindData data = (AppBindData)msg.obj;
handleBindApplication(data);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
break;
}
}

复制代码

可以看到这里有个H,H是主线程的一个Handler类,用于处理需要主线程处理的各类消息,包括BIND_SERVICE,LOW_MEMORY,DUMP_HEAP等等。接着看handleBindApplication:


private void handleBindApplication(AppBindData data) {
//...
try {
final ClassLoader cl = instrContext.getClassLoader();
mInstrumentation = (Instrumentation)
cl.loadClass(data.instrumentationName.getClassName()).newInstance();
}
//...
Application app;
final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskWrites();
final StrictMode.ThreadPolicy writesAllowedPolicy = StrictMode.getThreadPolicy();
try {
// If the app is being launched for full backup or restore, bring it up in
// a restricted environment with the base application class.
app = data.info.makeApplication(data.restrictedBackupMode, null);
mInitialApplication = app;
// don't bring up providers in restricted mode; they may depend on the
// app's custom Application class
if (!data.restrictedBackupMode) {
if (!ArrayUtils.isEmpty(data.providers)) {
installContentProviders(app, data.providers);
}
}

// Do this after providers, since instrumentation tests generally start their
// test thread at this point, and we don't want that racing.
try {
mInstrumentation.onCreate(data.instrumentationArgs);
}
//...
try {
mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!mInstrumentation.onException(app, e)) {
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
}
//...
}

这里信息量就多了,一点点的看:



  • 首先,创建了Instrumentation,也就是上文一开始startActivity的第一步。每个应用程序都有一个Instrumentation,用于管理这个进程,比如要创建Activity的时候,首先就会执行到这个类里面。
  • makeApplication方法,创建了Application,终于到这一步了。最终会走到newApplication方法,执行Application的attach方法。

public Application newApplication(ClassLoader cl, String className, Context context)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
Application app = getFactory(context.getPackageName())
.instantiateApplication(cl, className);
app.attach(context);
return app;
}

attach方法有了,onCreate方法又是何时调用的呢?马上来了:


instrumentation.callApplicationOnCreate(app);

public void callApplicationOnCreate(Application app) {
app.onCreate();
}

也就是创建Application->attach->onCreate调用顺序。


等等,在onCreate之前还有一句重要的代码:


installContentProviders

这里就是启动Provider的相关代码了,具体逻辑就不分析了。


第六关:启动Activity

说完bindApplication,该说说后续了,上文第五关说到,bindApplication方法之后执行的是attachApplication方法,最终会执行到ActivityThread的handleLaunchActivity方法:


public Activity handleLaunchActivity(ActivityClientRecord r,
PendingTransactionActions pendingActions, Intent customIntent) {
//...
WindowManagerGlobal.initialize();
//...
final Activity a = performLaunchActivity(r, customIntent);
//...
return a;
}

首先,初始化了WindowManagerGlobal,这是个啥呢? 没错,就是WindowManagerService了,也为后续窗口显示等作了准备。


继续看performLaunchActivity:


private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
//创建ContextImpl
ContextImpl appContext = createBaseContextForActivity(r);
Activity activity = null;
try {
java.lang.ClassLoader cl = appContext.getClassLoader();
//创建Activity
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
}

try {
if (activity != null) {
//完成activity的一些重要数据的初始化
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback,
r.assistToken);

if (customIntent != null) {
activity.mIntent = customIntent;
}

//设置activity的主题
int theme = r.activityInfo.getThemeResource();
if (theme != 0) {
activity.setTheme(theme);
}

//调用activity的onCreate方法
if (r.isPersistable()) {
mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
} else {
mInstrumentation.callActivityOnCreate(activity, r.state);
}
}
}

return activity;
}

哇,终于看到onCreate方法了。稳住,还是一步步看看这段代码。


首先,创建了ContextImpl对象,ContextImpl可能有的朋友不知道是啥,ContextImpl继承自Context,其实就是我们平时用的上下文。有的同学可能表示,这不对啊,获取上下文明明获取的是Context对象。来一起跟随源码看看。


//Activity.java
Context mBase;

@Override
public Executor getMainExecutor() {
return mBase.getMainExecutor();
}

@Override
public Context getApplicationContext() {
return mBase.getApplicationContext();
}

这里可以看到,我们平时用的上下文就是这个mBase,那么找到这个mBase是啥就行了:


protected void attachBaseContext(Context base) {
if (mBase != null) {
throw new IllegalStateException("Base context already set");
}
mBase = base;
}

//一层层往上找

final void attach(Context context, ActivityThread aThread,
Instrumentation instr, IBinder token, int ident,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id,
NonConfigurationInstances lastNonConfigurationInstances,
Configuration config, String referrer, IVoiceInteractor voiceInteractor) {

attachBaseContext(context);

mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
if (info.softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED) {
mWindow.setSoftInputMode(info.softInputMode);
}


}

这不就是,,,刚才一开始performLaunchActivity方法里面的attach吗?太巧了,所以这个ContextImpl就是我们平时所用的上下文。


顺便看看attach还干了啥?新建了PhoneWindow,建立自己和Window的关联,并设置了setSoftInputMode等等。


ContextImpl创建完之后,会通过类加载器创建Activity的对象,然后设置好activity的主题,最后调用了activity的onCreate方法。


总结

再一起捋一遍App的启动流程:



  • Launcher被调用点击事件,转到Instrumentation类的startActivity方法。
  • Instrumentation通过跨进程通信告诉AMS要启动应用的需求。
  • AMS反馈Launcher,让Launcher进入Paused状态
  • Launcher进入Paused状态,AMS转到ZygoteProcess类,并通过socket与Zygote通信,告知Zygote需要新建进程。
  • Zygote fork进程,并调用ActivityThread的main方法,也就是app的入口。
  • ActivityThread的main方法新建了ActivityThread实例,并新建了Looper实例,开始loop循环。
  • 同时ActivityThread也告知AMS,进程创建完毕,开始创建Application,Provider,并调用Applicaiton的attach,onCreate方法。
  • 最后就是创建上下文,通过类加载器加载Activity,调用Activity的onCreate方法。

至此,应用启动完毕。


当然,分析源码的目的一直都不是为了学知识而学,而是理解了这些基础,我们才能更好的解决问题。 学习了App的启动流程,我们可以再思考下一些之前没理解透的问题,比如启动优化


分析启动过程,其实可以优化启动速度的地方有三个地方:



  • Application的attach方法,MultiDexApplication会在方法里面会去执行MultiDex逻辑。所以这里可以进行MultiDex优化,比如今日头条方案就是单独启动一个进程的activity去加载MultiDex。
  • Application的onCreate方法,大量三方库的初始化都在这里进行,所以我们可以开启线程池,懒加载等等。把每个启动任务进行区分,哪些可以子线程运行,哪些有先后顺序。
  • Activity的onCreate方法,同样进行线程处理,懒加载。或者预创建Activity,提前类加载等等。

最后希望各位老铁都能有一个乖巧可爱漂亮的女儿/儿子。😊


附件

fork使用多线程
今日头条启动优化
app启动流程分析


作者:积木zz
来源:https://juejin.cn/post/6867744083809419277

收起阅读 »

女儿拿着小天才电话手表问我App启动流程(上)

首先,new一个女儿,var mDdaughter = new 女儿("6岁",“漂亮可爱”,“健康乖巧”,“最喜欢玩小天才电话手表和她的爸爸”)“爸爸爸爸,你说我玩的这个小天才电话手表怎么这么厉害,随便点一下这个小图片,这个应用就冒出来了,就可以听儿歌了。好...
继续阅读 »



前言

首先,new一个女儿,

var mDdaughter = new 女儿("6岁",“漂亮可爱”,“健康乖巧”,“最喜欢玩小天才电话手表和她的爸爸”)

好了,女儿有了,有一天,女儿问我:

“爸爸爸爸,你说我玩的这个小天才电话手表怎么这么厉害,随便点一下这个小图片,这个应用就冒出来了,就可以听儿歌了。好神奇啊。”

我心里一惊: img

小天才电话手表的系统就是Android,所以这不就是。。面试官常考的应用启动流程嘛!
女儿也要来面试我了吗!😭
好了,既然女儿问了,那就答吧。
但是,对付这个小小的0经验面试官,我该咋说呢?

解答小小面试官

女儿,你可以把手表里面想象成一个幼儿园,里面有一个老师,一个班长,一个班干部,以及一大堆小朋友。

  • 一个老师:Z老师(Zygote进程)

  • 一个班长:小A(ActivityManagerService)

  • 一个班干部:小L(Launcher桌面应用)

  • 一大堆小朋友:所有应用,包括音乐小朋友,聊天小朋友,日历小朋友等等。

img

应用启动过程就像一个小朋友被叫醒一样,开机之后呢,Z老师会依次叫醒班长和班干部(SystemServer#ActivityManagerService,Launcher),小L醒了之后就会去了解手表里有哪些小朋友,长什么样(icon,name),家庭信息(包名,androidmanifest)等等,然后一个个把小朋友的照片(icon)贴到自己的身上。比如有音乐小朋友,聊天小朋友,日历小朋友,其实也就是你手表上这个桌面啦。

这时候你要点开一个音乐小朋友呢(startActivity),小L就会通知班长小A(Binder),小A知道了之后,让小L自己休息下(Paused),然后就去找Z老师了。Z老师就负责叫音乐小朋友起床了(fork进程,启动ActivityThread),音乐小朋友起来后就又找小A带她去洗脸刷牙(启动ApplicationThread,Activity),都弄完了就可以进行各种表演了,唱歌啊,跳舞啊。

不是很明白啊?我们一起聊个天你就懂了,假如我是Launcher

img

img

女儿似懂非懂的给我点了一个赞👍,爸爸你真棒。

十五年后

mDdaughter.grow(15)
mDdaughter.study("Android")

过了十五年,女儿已经21岁了,正在学习Android,考虑要不要女从父业。

这天,她一脸疑惑的来找我: “爸,这个app启动到底是怎么个流程啊,我看了好久还是不大明白,要不你再跟我详细讲一遍吧?” “好嘞,别担心,我这次详细跟你说说”

解答Android程序媛

还记得我小时候跟你说过的故事吗,Android系统就像一个幼儿园,有一个大朋友叫Launcher,身上会贴很多其他小朋友的名片。这个Launcher就是我们的桌面了,它通过PackageManagerService获知了系统里所有应用的信息,并展示了出来,当然它本身也是一个应用。

通过点击一个应用图标,也就是触发了点击事件,最后会执行到startActivity方法。这里也就和启动Activity步骤重合上了。

那么这个startActivity干了啥?是怎么通过重重关卡唤醒这个应用的?

首先,介绍下系统中那些重要的成员,他们在app启动流程中都担任了重要的角色.

系统成员介绍

  • init进程,Android系统启动后,Zygote并不是第一个进程,而是linux的根进程init进程,然后init进程才会启动Zygote进程。

  • Zygote进程,所有android进程的父进程,当然也包括SystemServer进程

  • SystemServer进程,正如名字一样,系统服务进程,负责系统中大大小小的事物,为此也是启动了三员大将(ActivityManagerService,PackageManagerService,WindowManagerService)以及binder线程池。

  • ActivityManagerService,主要负责系统中四大组件的启动、切换、调度及应用进程的管理和调度等工作,对于一些进程的启动,都会通过Binder通信机制传递给AMS,再处理给Zygote。

  • PackageManagerService,主要负责应用包的一些操作,比如安装,卸载,解析AndroidManifest.xml,扫描文件信息等等。

  • WindowManagerService,主要负责窗口相关的一些服务,比如窗口的启动,添加,删除等。

  • Launcher,桌面应用,也是属于应用,也有自己的Activity,一开机就会默认启动,通过设置Intent.CATEGORY_HOME的Category隐式启动。

搞清楚这些成员,就跟随我一起看看怎么过五关斩六将,最终启动了一个App。

第一关:跨进程通信,告诉系统我的需求

首先,要告诉系统,我Launcher要启动一个应用了,调用Activity.startActivityForResult方法,最终会转到mInstrumentation.execStartActivity方法。 由于Launcher自己处在一个单独的进程,所以它需要跨进程告诉系统服务我要启动App的需求。 找到要通知的Service,名叫ActivityTaskManagerService,然后使用AIDL,通过Binder与他进行通信。

这里的简单说下ActivityTaskManagerService(简称ATMS)。原来这些通信工作都是属于ActivityManagerService,现在分了一部分工作给到ATMS,主要包括四大组件的调度工作。也是由SystemServer进程直接启动的,相关源码可见ActivityManagerService.Lifecycle.startService方法,感兴趣朋友可以自己看看。

接着说跨进程通信,相关代码如下:

//Instrumentation.java
int result = ActivityTaskManager.getService()
  .startActivity(whoThread, who.getBasePackageName(), intent,
                  intent.resolveTypeIfNeeded(who.getContentResolver()),
                  token, target != null ? target.mEmbeddedID : null,
                  requestCode, 0, null, options);


//ActivityTaskManager.java            
public static IActivityTaskManager getService() {
   return IActivityTaskManagerSingleton.get();
}
private static final Singleton<IActivityTaskManager> IActivityTaskManagerSingleton =
   new Singleton<IActivityTaskManager>() {
   @Override
   protected IActivityTaskManager create() {
       final IBinder b = ServiceManager.getService(Context.ACTIVITY_TASK_SERVICE);
       return IActivityTaskManager.Stub.asInterface(b);
  }
};

//ActivityTaskManagerService.java
public class ActivityTaskManagerService extends IActivityTaskManager.Stub

   public static final class Lifecycle extends SystemService {
       private final ActivityTaskManagerService mService;

       public Lifecycle(Context context) {
           super(context);
           mService = new ActivityTaskManagerService(context);
      }

       @Override
       public void onStart() {
           publishBinderService(Context.ACTIVITY_TASK_SERVICE, mService);
           mService.start();
      }
  }

startActivity我们都很熟悉,平时启动Activity都会使用,启动应用也是从这个方法开始的,也会同样带上intent信息,表示要启动的是哪个Activity。

另外要注意的一点是,startActivity之后有个checkStartActivityResult方法,这个方法是用作检查启动Activity的结果。当启动Activity失败的时候,就会通过这个方法抛出异常,比如有我们常见的问题:未在AndroidManifest.xml注册。

public static void checkStartActivityResult(int res, Object intent) {
   switch (res) {
       case ActivityManager.START_INTENT_NOT_RESOLVED:
       case ActivityManager.START_CLASS_NOT_FOUND:
           if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
               throw new ActivityNotFoundException(
               "Unable to find explicit activity class "
               + ((Intent)intent).getComponent().toShortString()
               + "; have you declared this activity in your AndroidManifest.xml?");
           throw new ActivityNotFoundException(
               "No Activity found to handle " + intent);
       case ActivityManager.START_PERMISSION_DENIED:
           throw new SecurityException("Not allowed to start activity "
                                       + intent);
       case ActivityManager.START_FORWARD_AND_REQUEST_CONFLICT:
           throw new AndroidRuntimeException(
               "FORWARD_RESULT_FLAG used while also requesting a result");
       case ActivityManager.START_NOT_ACTIVITY:
           throw new IllegalArgumentException(
               "PendingIntent is not an activity");
           //...
  }
}

第二关:通知Launcher可以休息了

ATMS收到要启动的消息后,就会通知上一个应用,也就是Launcher可以休息会了,进入Paused状态。

//ActivityStack.java

private boolean resumeTopActivityInnerLocked(ActivityRecord prev, ActivityOptions options) {
   //...
   ActivityRecord next = topRunningActivityLocked(true /* focusableOnly */);
   //...
   boolean pausing = getDisplay().pauseBackStacks(userLeaving, next, false);
   if (mResumedActivity != null) {
       if (DEBUG_STATES) Slog.d(TAG_STATES,
                                "resumeTopActivityLocked: Pausing " + mResumedActivity);
       pausing |= startPausingLocked(userLeaving, false, next, false);
  }
   //...

   if (next.attachedToProcess()) {
       //应用已经启动
       try {
           //...
           transaction.setLifecycleStateRequest(
               ResumeActivityItem.obtain(next.app.getReportedProcState(),
                                         getDisplay().mDisplayContent.isNextTransitionForward()));
           mService.getLifecycleManager().scheduleTransaction(transaction);
           //...
      } catch (Exception e) {
           //...
           mStackSupervisor.startSpecificActivityLocked(next, true, false);
           return true;
      }
       //...
       // From this point on, if something goes wrong there is no way
       // to recover the activity.
       try {
           next.completeResumeLocked();
      } catch (Exception e) {
           // If any exception gets thrown, toss away this
           // activity and try the next one.
           Slog.w(TAG, "Exception thrown during resume of " + next, e);
           requestFinishActivityLocked(next.appToken, Activity.RESULT_CANCELED, null,
                                       "resume-exception", true);
           return true;
      }
  } else {
       //冷启动流程
       mStackSupervisor.startSpecificActivityLocked(next, true, true);
  }        
}

这里有两个类没有见过:

  • ActivityStack,是Activity的栈管理,相当于我们平时项目里面自己写的Activity管理类,用于管理Activity的状态啊,如栈出栈顺序等等。

  • ActivityRecord,代表具体的某一个Activity,存放了该Activity的各种信息。

startPausingLocked方法就是让上一个应用,这里也就是Launcher进入Paused状态。 然后就会判断应用是否启动,如果已经启动了,就会走ResumeActivityItem的方法,看这个名字,结合应用已经启动的前提,是不是已经猜到了它是干吗的?没错,这个就是用来控制Activity的onResume生命周期方法的,不仅是onResume还有onStart方法,具体可见ActivityThread的handleResumeActivity方法源码。

如果应用没启动就会接着走到startSpecificActivityLocked方法,接着看。

第三关:是否已启动进程,否则创建进程

Launcher进入Paused之后,ActivityTaskManagerService就会判断要打开的这个应用进程是否已经启动,如果已经启动,则直接启动Activity即可,这也就是应用内的启动Activity流程。如果进程没有启动,则需要创建进程。

这里有两个问题:

  • 怎么判断应用进程是否存在呢?如果一个应用已经启动了,会在ATMS里面保存一个WindowProcessController信息,这个信息包括processName和uid,uid则是应用程序的id,可以通过applicationInfo.uid获取。processName则是进程名,一般为程序包名。所以判断是否存在应用进程,则是根据processName和uid去判断是否有对应的WindowProcessController,并且WindowProcessController里面的线程不为空。代码如下:

//ActivityStackSupervisor.java
void startSpecificActivityLocked(ActivityRecord r, boolean andResume, boolean checkConfig) {
   // Is this activity's application already running?
   final WindowProcessController wpc =
       mService.getProcessController(r.processName, r.info.applicationInfo.uid);

   boolean knownToBeDead = false;
   if (wpc != null && wpc.hasThread()) {
       //应用进程存在
       try {
           realStartActivityLocked(r, wpc, andResume, checkConfig);
           return;
      }
  }
}

//WindowProcessController.java
IApplicationThread getThread() {
   return mThread;
}

boolean hasThread() {
   return mThread != null;
}
  • 还有个问题就是怎么创建进程?还记得Z老师吗?对,就是Zygote进程。之前说了他是所有进程的父进程,所以就要通知Zygote去fork一个新的进程,服务于这个应用。

//ZygoteProcess.java
private Process.ProcessStartResult attemptUsapSendArgsAndGetResult(
   ZygoteState zygoteState, String msgStr)
   throws ZygoteStartFailedEx, IOException {
   try (LocalSocket usapSessionSocket = zygoteState.getUsapSessionSocket()) {
       final BufferedWriter usapWriter =
           new BufferedWriter(
           new OutputStreamWriter(usapSessionSocket.getOutputStream()),
           Zygote.SOCKET_BUFFER_SIZE);
       final DataInputStream usapReader =
           new DataInputStream(usapSessionSocket.getInputStream());

       usapWriter.write(msgStr);
       usapWriter.flush();

       Process.ProcessStartResult result = new Process.ProcessStartResult();
       result.pid = usapReader.readInt();
       // USAPs can't be used to spawn processes that need wrappers.
       result.usingWrapper = false;

       if (result.pid >= 0) {
           return result;
      } else {
           throw new ZygoteStartFailedEx("USAP specialization failed");
      }
  }
}

可以看到,这里其实是通过socket和Zygote进行通信,BufferedWriter用于读取和接收消息。这里将要新建进程的消息传递给Zygote,由Zygote进行fork进程,并返回新进程的pid。

可能又会有人问了?fork是啥?为啥这里又变成socket进行IPC通信,而不是Bindler了?

  • 首先,fork()是一个方法,是类Unix操作系统上创建进程的主要方法。用于创建子进程(等同于当前进程的副本)。

  • 那为什么fork的时候不用Binder而用socket了呢?主要是因为fork不允许存在多线程,Binder通讯偏偏就是多线程。

问题总是在不断产生,总有好奇的朋友会接着问,为什么fork不允许存在多线程?

收起阅读 »

黑科技,Python 脚本帮你找出微信上删除你好友的人

查看被删的微信好友原理就是新建群组,如果加不进来就是被删好友了(不要在群组里讲话,别人是看不见的)用的是微信网页版的接口查询结果可能会引起一些心理上的不适,请小心使用..(逃还有些小问题:结果好像有疏漏一小部分,原因不明..最终会遗留下一个只有自己的群组,需要...
继续阅读 »



查看被删的微信好友

原理就是新建群组,如果加不进来就是被删好友了(不要在群组里讲话,别人是看不见的)

用的是微信网页版的接口

查询结果可能会引起一些心理上的不适,请小心使用..(逃

还有些小问题:

结果好像有疏漏一小部分,原因不明..

最终会遗留下一个只有自己的群组,需要手工删一下

没试过被拉黑的情况

新手步骤 Mac 上步骤:

  1. 在 Mac 上操作,下载代码文件wdf.py

  2. 打开 Terminal 输入:python +空格,然后拖动刚才下载的 wdf.py 到 Terminal 后回车。格式: python wdf.py

  3. 接下来按步骤操作即可;

代码如下:

#!/usr/bin/env python
# coding=utf-8

import os
import urllib, urllib2
import re
import cookielib
import time
import xml.dom.minidom
import json
import sys
import math

DEBUG = False

MAX_GROUP_NUM = 35 # 每组人数

QRImagePath = os.getcwd() + '/qrcode.jpg'

tip = 0
uuid = ''

base_uri = ''
redirect_uri = ''

skey = ''
wxsid = ''
wxuin = ''
pass_ticket = ''
deviceId = 'e000000000000000'

BaseRequest = {}

ContactList = []
My = []

def getUUID():
global uuid

url = 'https://login.weixin.qq.com/jslogin'
params = {
'appid': 'wx782c26e4c19acffb',
'fun': 'new',
'lang': 'zh_CN',
'_': int(time.time()),
}

request = urllib2.Request(url = url, data = urllib.urlencode(params))
response = urllib2.urlopen(request)
data = response.read()

# print data

# window.QRLogin.code = 200; window.QRLogin.uuid = "oZwt_bFfRg==";
regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)"'
pm = re.search(regx, data)

code = pm.group(1)
uuid = pm.group(2)

if code == '200':
return True

return False

def showQRImage():
global tip

url = 'https://login.weixin.qq.com/qrcode/' + uuid
params = {
't': 'webwx',
'_': int(time.time()),
}

request = urllib2.Request(url = url, data = urllib.urlencode(params))
response = urllib2.urlopen(request)

tip = 1

f = open(QRImagePath, 'wb')
f.write(response.read())
f.close()

if sys.platform.find('darwin') >= 0:
os.system('open %s' % QRImagePath)
elif sys.platform.find('linux') >= 0:
os.system('xdg-open %s' % QRImagePath)
else:
os.system('call %s' % QRImagePath)

print '请使用微信扫描二维码以登录'

def waitForLogin():
global tip, base_uri, redirect_uri

url = 'https://login.weixin.qq.com/cgi-bin/mmwebwx-bin/login?tip=%s&uuid=%s&_=%s' % (tip, uuid, int(time.time()))

request = urllib2.Request(url = url)
response = urllib2.urlopen(request)
data = response.read()

# print data

# window.code=500;
regx = r'window.code=(\d+);'
pm = re.search(regx, data)

code = pm.group(1)

if code == '201': #已扫描
print '成功扫描,请在手机上点击确认以登录'
tip = 0
elif code == '200': #已登录
print '正在登录...'
regx = r'window.redirect_uri="(\S+?)";'
pm = re.search(regx, data)
redirect_uri = pm.group(1) + '&fun=new'
base_uri = redirect_uri[:redirect_uri.rfind('/')]
elif code == '408': #超时
pass
# elif code == '400' or code == '500':

return code

def login():
global skey, wxsid, wxuin, pass_ticket, BaseRequest

request = urllib2.Request(url = redirect_uri)
response = urllib2.urlopen(request)
data = response.read()

# print data

'''

0
OK
xxx
xxx
xxx
xxx
1

'''

doc = xml.dom.minidom.parseString(data)
root = doc.documentElement

for node in root.childNodes:
if node.nodeName == 'skey':
skey = node.childNodes[0].data
elif node.nodeName == 'wxsid':
wxsid = node.childNodes[0].data
elif node.nodeName == 'wxuin':
wxuin = node.childNodes[0].data
elif node.nodeName == 'pass_ticket':
pass_ticket = node.childNodes[0].data

# print 'skey: %s, wxsid: %s, wxuin: %s, pass_ticket: %s' % (skey, wxsid, wxuin, pass_ticket)

if skey == '' or wxsid == '' or wxuin == '' or pass_ticket == '':
return False

BaseRequest = {
'Uin': int(wxuin),
'Sid': wxsid,
'Skey': skey,
'DeviceID': deviceId,
}

return True

def webwxinit():

url = base_uri + '/webwxinit?pass_ticket=%s&skey=%s&r=%s' % (pass_ticket, skey, int(time.time()))
params = {
'BaseRequest': BaseRequest
}

request = urllib2.Request(url = url, data = json.dumps(params))
request.add_header('ContentType', 'application/json; charset=UTF-8')
response = urllib2.urlopen(request)
data = response.read()

if DEBUG == True:
f = open(os.getcwd() + '/webwxinit.json', 'wb')
f.write(data)
f.close()

# print data

global ContactList, My
dic = json.loads(data)
ContactList = dic['ContactList']
My = dic['User']

ErrMsg = dic['BaseResponse']['ErrMsg']
if len(ErrMsg) > 0:
print ErrMsg

Ret = dic['BaseResponse']['Ret']
if Ret != 0:
return False

return True

def webwxgetcontact():

url = base_uri + '/webwxgetcontact?pass_ticket=%s&skey=%s&r=%s' % (pass_ticket, skey, int(time.time()))

request = urllib2.Request(url = url)
request.add_header('ContentType', 'application/json; charset=UTF-8')
response = urllib2.urlopen(request)
data = response.read()

if DEBUG == True:
f = open(os.getcwd() + '/webwxgetcontact.json', 'wb')
f.write(data)
f.close()

# print data

dic = json.loads(data)
MemberList = dic['MemberList']

# 倒序遍历,不然删除的时候出问题..
SpecialUsers = ['newsapp', 'fmessage', 'filehelper', 'weibo', 'qqmail', 'fmessage', 'tmessage', 'qmessage', 'qqsync', 'floatbottle', 'lbsapp', 'shakeapp', 'medianote', 'qqfriend', 'readerapp', 'blogapp', 'facebookapp', 'masssendapp', 'meishiapp', 'feedsapp', 'voip', 'blogappweixin', 'weixin', 'brandsessionholder', 'weixinreminder', 'wxid_novlwrv3lqwv11', 'gh_22b87fa7cb3c', 'officialaccounts', 'notification_messages', 'wxid_novlwrv3lqwv11', 'gh_22b87fa7cb3c', 'wxitil', 'userexperience_alarm', 'notification_messages']
for i in xrange(len(MemberList) - 1, -1, -1):
Member = MemberList[i]
if Member['VerifyFlag'] & 8 != 0: # 公众号/服务号
MemberList.remove(Member)
elif Member['UserName'] in SpecialUsers: # 特殊账号
MemberList.remove(Member)
elif Member['UserName'].find('@@') != -1: # 群聊
MemberList.remove(Member)
elif Member['UserName'] == My['UserName']: # 自己
MemberList.remove(Member)

return MemberList

def createChatroom(UserNames):
MemberList = []
for UserName in UserNames:
MemberList.append({'UserName': UserName})


url = base_uri + '/webwxcreatechatroom?pass_ticket=%s&r=%s' % (pass_ticket, int(time.time()))
params = {
'BaseRequest': BaseRequest,
'MemberCount': len(MemberList),
'MemberList': MemberList,
'Topic': '',
}

request = urllib2.Request(url = url, data = json.dumps(params))
request.add_header('ContentType', 'application/json; charset=UTF-8')
response = urllib2.urlopen(request)
data = response.read()

# print data

dic = json.loads(data)
ChatRoomName = dic['ChatRoomName']
MemberList = dic['MemberList']
DeletedList = []
for Member in MemberList:
if Member['MemberStatus'] == 4: #被对方删除了
DeletedList.append(Member['UserName'])

ErrMsg = dic['BaseResponse']['ErrMsg']
if len(ErrMsg) > 0:
print ErrMsg

return (ChatRoomName, DeletedList)

def deleteMember(ChatRoomName, UserNames):
url = base_uri + '/webwxupdatechatroom?fun=delmember&pass_ticket=%s' % (pass_ticket)
params = {
'BaseRequest': BaseRequest,
'ChatRoomName': ChatRoomName,
'DelMemberList': ','.join(UserNames),
}

request = urllib2.Request(url = url, data = json.dumps(params))
request.add_header('ContentType', 'application/json; charset=UTF-8')
response = urllib2.urlopen(request)
data = response.read()

# print data

dic = json.loads(data)
ErrMsg = dic['BaseResponse']['ErrMsg']
if len(ErrMsg) > 0:
print ErrMsg

Ret = dic['BaseResponse']['Ret']
if Ret != 0:
return False

return True

def addMember(ChatRoomName, UserNames):
url = base_uri + '/webwxupdatechatroom?fun=addmember&pass_ticket=%s' % (pass_ticket)
params = {
'BaseRequest': BaseRequest,
'ChatRoomName': ChatRoomName,
'AddMemberList': ','.join(UserNames),
}

request = urllib2.Request(url = url, data = json.dumps(params))
request.add_header('ContentType', 'application/json; charset=UTF-8')
response = urllib2.urlopen(request)
data = response.read()

# print data

dic = json.loads(data)
MemberList = dic['MemberList']
DeletedList = []
for Member in MemberList:
if Member['MemberStatus'] == 4: #被对方删除了
DeletedList.append(Member['UserName'])

ErrMsg = dic['BaseResponse']['ErrMsg']
if len(ErrMsg) > 0:
print ErrMsg

return DeletedList

def main():

opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cookielib.CookieJar()))
urllib2.install_opener(opener)

if getUUID() == False:
print '获取uuid失败'
return

showQRImage()
time.sleep(1)

while waitForLogin() != '200':
pass

os.remove(QRImagePath)

if login() == False:
print '登录失败'
return

if webwxinit() == False:
print '初始化失败'
return

MemberList = webwxgetcontact()

MemberCount = len(MemberList)
print '通讯录共%s位好友' % MemberCount

ChatRoomName = ''
result = []
for i in xrange(0, int(math.ceil(MemberCount / float(MAX_GROUP_NUM)))):
UserNames = []
NickNames = []
DeletedList = ''
for j in xrange(0, MAX_GROUP_NUM):
if i * MAX_GROUP_NUM + j >= MemberCount:
break

Member = MemberList[i * MAX_GROUP_NUM + j]
UserNames.append(Member['UserName'])
NickNames.append(Member['NickName'].encode('utf-8'))
                       
print '第%s组...' % (i + 1)
print ', '.join(NickNames)
print '回车键继续...'
raw_input()

# 新建群组/添加成员
if ChatRoomName == '':
(ChatRoomName, DeletedList) = createChatroom(UserNames)
else:
DeletedList = addMember(ChatRoomName, UserNames)

DeletedCount = len(DeletedList)
if DeletedCount > 0:
result += DeletedList

print '找到%s个被删好友' % DeletedCount
# raw_input()

# 删除成员
deleteMember(ChatRoomName, UserNames)

# todo 删除群组


resultNames = []
for Member in MemberList:
if Member['UserName'] in result:
NickName = Member['NickName']
if Member['RemarkName'] != '':
NickName += '(%s)' % Member['RemarkName']
resultNames.append(NickName.encode('utf-8'))

print '---------- 被删除的好友列表 ----------'
print '\n'.join(resultNames)
print '-----------------------------------'

# windows下编码问题修复
# http://blog.csdn.net/heyuxuanzee/article/details/8442718
class UnicodeStreamFilter:  
def __init__(self, target):  
self.target = target  
self.encoding = 'utf-8'  
self.errors = 'replace'  
self.encode_to = self.target.encoding  
def write(self, s):  
if type(s) == str:  
s = s.decode('utf-8')  
s = s.encode(self.encode_to, self.errors).decode(self.encode_to)  
self.target.write(s)  
 
if sys.stdout.encoding == 'cp936':  
sys.stdout = UnicodeStreamFilter(sys.stdout)

if __name__ == '__main__' :

print '本程序的查询结果可能会引起一些心理上的不适,请小心使用...'
print '回车键继续...'
raw_input()

main()

print '回车键结束'
raw_input()

作者: 0x5e(github id)

来源:https://juejin.cn/post/6844903425629487112

收起阅读 »

js打包时间缩短90%,bundleless生产环境实践总结

最近尝试将bundleless的构建结果直接用到了线上生产环境,因为bundleless只会编译代码,不会打包,因此构建速度极快,同比bundle模式时间缩短了90%以上。得益于大部分浏览器都已经支持了http2和浏览器的es module,对于我们没有强兼容...
继续阅读 »




最近尝试将bundleless的构建结果直接用到了线上生产环境,因为bundleless只会编译代码,不会打包,因此构建速度极快,同比bundle模式时间缩短了90%以上。得益于大部分浏览器都已经支持了http2和浏览器的es module,对于我们没有强兼容场景的中后台系统,将bundleless构建结果应用于线上是有可能的。本文主要介绍一下,本人在使用bundleless构建工具实践中遇到的问题。

  • 起源

  • 结合snowpack实践

  • snowpack的Streaming Imports

  • 性能比较

  • 总结

  • 附录snowpack和vite的对比


本文原文来自我的博客: github.com/fortheallli…

一、起源

1.1 从http2谈起

以前因为http1.x不支持多路服用, HTTP 1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8个的TCP链接请求限制.因此我们需要做的就是将同域的一些静态资源比如js等,做一个资源合并,将多次请求不同的js文件,合并成单次请求一个合并后的大js文件。这就是webpack等bundle工具的由来。

而从http2开始,实现了TCP链接的多路复用,因此同域名下不再有请求并发数的限制,我们可以同时请求同域名的多个资源,这个并发数可以很大,比如并发10,50,100个请求同时去请求同一个服务下的多个资源。

因为http2实现了多路复用,因此一定程度上,将多个静态文件打包到一起,从而减少请求次数,就不是必须的

主流浏览器对http2的支持情况如下:

Lark20210825-203949

除了IE以外,大部分浏览器对http2的支持性都很好,因为我的项目不需要兼容IE,同时也不需要兼容低版本浏览器,不需要考虑不支持http2的场景。(这也是我们能将不bundle的代码用于线上生产环境的前提之一)

1.2 浏览器esm

对于es modules,我们并不陌生,什么是es modules也不是本文的重点,一些流行的打包构建工具比如babel、webpack等早就支持es modules。

我们来看一个最简单的es modules的写法:

//main.js
import a from 'a.js'
console.log(a)

//a.js
export let  a = 1

上述的es modules就是我们经常在项目中使用的es modules,这种es modules,在支持es6的浏览器中是可以直接使用的。

我们来举一个例子,直接在浏览器中使用es modules

<html  lang="en">
   <body>
       <div id="container">my name is {name}</div>
       <script type="module">
          import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js'
          new Vue({
            el: '#container',
            data:{
               name: 'Bob'
            }
          })
       </script>
   </body>
</html>

上述的代码中我们直接可以运行,我们根据script的type="module"可以判断浏览器支不支持es modules,如果不支持,该script里面的内容就不会运行。

首先我们来看主流浏览器对于ES modules的支持情况:

Lark20201119-151747

从上图可以看出来,主流的Edge, Chrome, Safari, and Firefox (+60)等浏览器都已经开始支持es modules。

同样的因为我们的中后台项目不需要强兼容,因此不需要兼容不支持esm的浏览器(这也是我们能将不bundle的代码用于线上生产环境的前提之二)。

1.3 小结

浏览器对于http2和esm的支持,使得我们可以减少模块的合并,以及减少对于js模块化的处理。

  • 如果浏览器支持http2,那么一定程度上,我们不需要合并静态资源

  • 如果浏览器支持esm,那么我们就不需要通过构建工具去维护复杂的模块依赖和加载关系。

这两点正是webpack等打包工具在bundle的时候所做的事情。浏览器对于http2和esm的支持使得我们减少bundle代码的场景。

二、结合snowpack实践

我们比较了snowpack和vite,最后选择采用了snowpack(选型的原因以及snowpack和vite的对比看最后附录),本章节讲讲如何结合snowpack构建工具,构建出不打包形式的线上代码。

2.1 snowpack的基础用法

我们的中后台项目是react和typescript编写的,我们可以直接使用snowpack相应的模版:

npx create-snowpack-app myproject --template @snowpack/app-template-react-typescript

snowpack构建工具内置了tsc,可以处理tsx等后缀的文件。上述就完成了项目初始化。

2.2 前端路由处理

前端路由我们直接使用react-router或者vue-router等,需要注意的时,如果是在开发环境,那么必须要指定在snowpack.config.mjs配置文件,在刷新时让匹配到前端路由:

snowpack.config.mjs
...
 routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
...

类似的配置跟webpack devserver等一样,使其在后端路由404的时候,获取前端静态文件,从而执行前端路由匹配。

2.3 css、jpg等模块的处理

在snowpack中同样也自带了对css和image等文件的处理。

  • css

以sass为例,

snowpack.config.mjs

plugins: [
    '@snowpack/plugin-sass',
    {
      /* see options below */
    },
  ],

只需要在配置中增加一个sass插件就能让snowpack支持sass文件,此外,snowpack也同样支持css module。.module.css或者.module.scss命名的文件就默认开启了css module。此外,css最后的结果都是通过编译成js模块,通过动态创建style标签,插入到body中的。

//index.module.css文件
.container{
   padding: 20px;
}

snowpack构建处理后的css.proxy.js文件为:

export let code = "._container_24xje_1 {\n  padding: 20px;\n}";
let json = {"container":"_container_24xje_1"};
export default json;

// [snowpack] add styles to the page (skip if no document exists)
if (typeof document !== 'undefined') {
 const styleEl = document.createElement("style");
 const codeEl = document.createTextNode(code);
 styleEl.type = 'text/css';

 styleEl.appendChild(codeEl);
 document.head.appendChild(styleEl);
}

上述的例子中我们可以看到。最后css的构建结果是一段js代码。在body中动态插入了style标签,就可以让原始的css样式在系统中生效。

  • jpg,png,svg等

如果处理的是图片类型,那么snowpack同样会将图片编译成js.

//logo.svg.proxy.js
export default "../dist/assets/logo.svg";

snowpack没有对图片做任何的处理,只是把图片的地址,包含到了一个js模块文件导出地址中。值得注意的是在浏览器es module中,import 动作类似一个get请求,import from可以是一个图片地址,浏览器es module自身可以处理图片等形式。因此在.js文件结尾的模块中,export 的可以是一个图片。

snowpack3.5.0以下的版本在使用css module的时候会丢失hash,需要升级到最新版本。

2.4 按需加载处理

snowpack默认是不打包的。只对每一个文件都做一些简单的模块处理(将非js模块转化成js模块)和语法处理,因此天然支持按需加载,snowpack支持React.lazy的写法,在react的项目中,只要正常使用React.Lazy就能实现按需加载。

2.5 文件hash处理

在最后构建完成后,在发布构建结果的时候,为了处理缓存,常见的就是跟静态文件增加hash,snowpack也提供了插件机制,插件会处理snowpack构建前的所有文件的内容,做为content转入到插件中,经过插件的处理转换后得到新的content.

可以通过snowpack-files-hash插件来实现给文件增加hash。

2.6 公用esm模块托管

snowpack对于项目构建的bundleless的代码可以直接跑在线上,在bundless的构建结果中,我们想进一步减少构建结果文件大小。以bundleless的方式构建的代码,默认在处理三方npm包依赖的时候,虽然不会打包,snowpack对项目中node_modules中的依赖重新编译成esm形式,然后放在一个新的静态目录下。因此最后构建的代码包含了两个部分:

项目本身的代码,将node_modules中的依赖处理成esm后的静态文件

其中node_modules中的依赖处理成esm后的静态文件,可以以cdn或者其他服务形式来托管。这样我们每次都不需要在构建的时候处理node_modules中的依赖。在项目本身的代码中,如果引用了npm包,只需要将其指向一个cdn地址即可。这样处理后的,构建的代码就变成:

只有项目本身的代码(项目中对于三方插件的引入,直接使用三方插件的cdn地址)

进一步想,如果我们使用了托管所有npm包(es module形式)的cdn地址之后,那么在本地开发或者线上构建的过程中,我们甚至不需要去维护本地的node_modules目录,以及yarn-lock或者package-lock文件。我们需要做的,仅仅是一个map文件进行版本管理。保存项目中的npm包名和该包相对应的cdn地址。

比如:

//config.map.json
{
 "react": "https://cdn.skypack.dev/react@17.0.2",
 "react-dom": "https://cdn.skypack.dev/react-dom@17.0.2",
}

通过这个map文件,不管是在开发还是线上,只要把:

import React from 'react'

替换成

import React from "https://cdn.skypack.dev/react@17.0.2"

就能让代码在开发环境或者生产环境中跑起来。如此简化之后,我们不论在开发环境还是生产环境都不需要在本地维护node_modules相关的文件,进一步可以减少打包时间。同时包管理也更加清晰,仅仅是一个简单的json文件,一对固定意义的key/value,简单纯粹

我们提到了一个托管了的npm包的有es module形式的cdn服务,上述以skypack为例,这对比托管了npm包cjs形式的cdn服务unpkg,两者的区别就是,unpkg所托管的npm包,大部分是cjs形式的,cjs形式的npm包,是不能直接用于浏览器的esm中的。skypack所做的事情就是将大部分npm包从cjs形式转化成esm的形式,然后存储和托管esm形式的结果。

三、snowpack的Streaming Imports

在2.7中我们提到了在dev开发环境使用了skypack,那么本地不需要node_modules,甚至不需要yarn-lock和package-lock等文件,只需要一个json文件,简单的、纯粹的,只有一对固定意义的key/value。在snowpack3.x就提供了这么一个功能,称之为Streaming Imports。

3.1 snowpack和skypack

在snowpack3.x在dev环境支持skypack:

// snowpack.config.mjs
export default {
 packageOptions: {
   source: 'remote',
},
};

如此,在dev的webserver过程中,就是直接下载skypack中相应的esm形式的npm包,放在最后的结果中,而不需要在本地做一个cjs到esm的转换。这样做有几点好处:

  • 速度快: 不需要npm install一个npm包,然后在对其进行build转化成esm,Streaming Imports可以直接从一个cdn地址直接下载esm形式的依赖

  • 安全:业务代码中不需要处理公共npm包cjs到esm的转化,业务代码和三方依赖分离,三方依赖交给skypack处理

3.2 依赖控制

Streaming Imports自身也实现了一套简单的依赖管理,有点类似go mod。是通过一个叫snowpack.deps.json文件来实现的。跟我们在2.7中提到的一样,如果使用托管cdn,那么本地的pack-lock和yarn-lock,甚至node_modules是不需要存在的,只需要一个简单纯粹的json文件,而snowpack中就是通过snowpack.deps.json来实现包的依赖管理的。

我们安装一个npm包时,我们以安装ramda为例:

npx snowpack ramda

在snowpack.deps.json中会生成:

{
 "dependencies": {
   "ramda": "^0.27.1",
},
 "lock": {
   "ramda#^0.27.1": "ramda@v0.27.1-3ePaNsppsnXYRcaNcaWn",
}
}

安装过程的命令行如下所示:

飞书20210831-211844

从上图可以看出来,通过npx snowpack安装的依赖是从skypack cdn直接请求的。

特别的,如果项目需要支持typescript,那么我们需要将相应的npm包的声明文件types下载到本地,skypack同样也支持声明文件的下载,只需要在snowpack的配置文件中增加:

// snowpack.config.mjs
export default {
 packageOptions: {
   source: 'remote',
   types:true //增加type=true
},
};

snowpack会把types文件下载到本地的.snowpack目录下,因此在tsc编译的时候需要指定types的查找路径,在tsconfig.json中增加:

//tsconfig.json
"paths": {
     "*":[".snowpack/types/*"]
  },

3.3 build环境

snowpack的Streaming Imports,在dev可以正常工作,dev的webserver中在请求npm包的时候会将请求代理到skypack,但是在build环境的时候,还是需要其他处理的,在我们的项目中,在build的时候可以用一个插件snowpack-plugin-skypack-replacer,将build后的代码引入npm包的时候,指向skypack。

build后的线上代码举例如下:

import * as __SNOWPACK_ENV__ from '../_snowpack/env.271340c8a413.js';
import.meta.env = __SNOWPACK_ENV__;

import ReactDOM from "https://cdn.skypack.dev/react-dom@^17.0.2";
import App from "./App.e1841499eb35.js";
import React from "https://cdn.skypack.dev/react@^17.0.2";
import "./index.css.proxy.9c7da16f4b6e.js";

const start = async () => {
 await ReactDOM.render(/* @__PURE__ */ React.createElement(App, null), document.getElementById("root"));
};
start();
if (undefined /* [snowpack] import.meta.hot */ ) {
 undefined /* [snowpack] import.meta.hot */ .accept();
}

从上述可以看出,build之后的代码,通过插件将:

import React from 'react'
//替换成了
import React from "https://cdn.skypack.dev/react@^17.0.2";

四、性能比较

4.1 lighthouse对比

简单的使用lighthouse来对比bundleless和bundle两种不同构建方式网页的性能。

  • bundleless的前端简单性能测试:

img

  • bundle的前端性能测试:

img

对比发现,这里两个网站都是同一套代码,相同的部署环境,一套是构建的时候是bundleless,利用浏览器的esm,另一个是传统的bundle模式,发现性能上并没有明显的区别,至少bundleless简单的性能测试方面没有明显差距。

4.2构建时间对比

bundleless构建用于线上,主要是减少了构建的时间,我们传统的bundle的代码,一次编译打包等可能需要几分钟甚至十几分钟。在我的项目中,bundleless的构建只需要4秒。

飞书20210901-165311

同一个项目,用webpack构建bundle的情况下需要60秒左右。

4.3构建产物体积对比

bundleless构建出的产物,一般来说也只有bundle情况下的1/10.这里不一一举例。

五、总结

在没有强兼容性的场景,特别是中后台系统,bundleless的代码直接跑在线上,是一种可以尝试的方案,上线的时间会缩短90%,不过也有一些问题需要解决,首先需要保证托管esm资源的CDN服务的稳定性,且要保障被托管的esm资源在浏览器运行不会出现异常。我们运行了一些常见的npm包,发现并没有异常情况,不过后续需要更多的测试。

六、附录:snowpack和vite的对比

6.1 相同点

snowpack和vite都是bundleless的构建工具,都利用了浏览器的es module来减少对静态文件的打包,从而减少热更新的时间,从而提高开发体验。原理都是将本地安装的依赖重新编译成esm形式,然后放在本地服务的静态目录下。snowpack和vite有很多相似点

  • 在dev环境都将本地的依赖进行二次处理,对于本地node_module目录下的npm包,通过其他构建工具转换成esm。然后将所有转换后的esm文件放在本地服务的静态目录下

  • 都支持css、png等等静态文件,不需要安装其他插件。特别对于css,都默认支持css module

  • 默认都支持jsx,tsx,ts等扩展名的文件

  • 框架无关,都支持react、vue等主流前端框架,不过vite对于vue的支持性是最好的。

6.2 不同点

dev构建: snowpack和vite其实大同小异,在dev环境都可以将本地node_modules中npm包,通过esinstall等编译到本地server的静态目录。不同的是在dev环境

  • snowpack是通过rollup来将node_modules的包,重新进行esm形式的编译

  • vite则是通过esbuild来将node_modules的包,重新进行esm形式的编译

因此dev开发环境来看,vite的速度要相对快一些,因为一个npm包只会重新编译一次,因此dev环境速度影响不大,只是在初始化项目冷启动的时候时间有一些误差,此外snowpack支持Streaming Imports,可以在dev环境直接用托管在cdn上的esm形式的npm包,因此dev环境性能差别不大。

build构建:

在生产环境build的时候,vite是不支持unbundle的,在bundle模式下,vite选择采用的是rollup,通过rollup来打包出线上环境运行的静态文件。vite官方支持且仅支持rollup,这样一定程度上可以保持一致性,但是不容易解耦,从而结合非rollup构建工具来打包。而snowpack默认就是unbundle的,这种unbundle的默认形式,对构建工具就没有要求,对于线上环境,即可以使用rollup,也可以使用webpack,甚至可以选择不打包,直接使用unbundle。

可以用两个表格来总结如上的结论:

dev开发环境:

产品dev环境构建工具
snowpackrollup(或者使用Streaming imports)
viteesbuild

build生产环境:

产品build构建工具
snowpack1.unbundle(esbuild) 2.rollup 3.webpack...
viterollup(且不支持unbundle)

6.3 snowpack支持Streaming Imports

Streaming Imports是一个新特性,他允许用户,不管是生产环境还是开发环境,都不需要在本地使用npm/yarn来维护一个lock文件,从而下载应用中所使用的npm包到本地的node_module目录下。通过使用Streaming Imports,可以维护一个map文件,该map文件中的key是包名,value直接指向托管该npm包esm文件形式的cdn服务器的地址。

6.4 vite的一些优点

vite相对于snowpack有一下几个优点,不过个人以为都不算特别有用的一些优点。

  • 多页面支持,除了根目录的root/index.html外,还支持其他根目录以外的页面,比如nest/index.html

  • 对于css预处理器支持更好(这点个人没发现)

  • 支持css代码的code-splitting

  • 优化了异步请求多个chunk文件(不分场景可以同步进行,从而一定程度下减少请求总时间)

6.5 总结

如果想在生产环境使用unbundle,那么vite是不行的,vite对于线上环境的build,是必须要打包的。vite优化的只是开发环境。而snowpack默认就是unbundle的,因此可以作为前提在生产环境使用unbundle.此外,snowpack的Streaming Imports提供了一套完整的本地map的包管理,不需要将npm包安装到本地,很方便我们在线上和线下使用cdn托管公共库。


作者:yuxiaoliang
来源:https://juejin.cn/post/7034484346874986533

收起阅读 »

Android 手把手带你搭建一个组件化项目架构

🔥 一、组件化作为一个单工程撸到底的开发人员,想试着将项目进行组件化改造,说动就动。毕竟技术都是写出来的,看着文章感觉懂了,但是实际开发中还是能遇到各种各样的问题,开始搞起来。💥 1.1 为什么使用组件化一直使用单工程撸到底,项目越来越大导致出现了不少的问题:...
继续阅读 »



🔥 一、组件化

作为一个单工程撸到底的开发人员,想试着将项目进行组件化改造,说动就动。毕竟技术都是写出来的,看着文章感觉懂了,但是实际开发中还是能遇到各种各样的问题,开始搞起来。

💥 1.1 为什么使用组件化

一直使用单工程撸到底,项目越来越大导致出现了不少的问题:

  • 查找问题慢:定位问题,需要在多个代码混合的模块中寻找和跳转。

  • 开发维护成本增加:避免代码的改动影响其它业务的功能,导致开发和维护成本不断增加。

  • 编译时间长:项目工程越大,编译完整代码所花费的时间越长。

  • 开发效率低:多人协作开发时,开发风格不一,又很难将业务完全分割,大家互相影响,导致开发效率低下。

  • 代码复用性差:写过的代码很难抽离出来再次利用。

💥 1.2 模块化与组件化

🌀 1.2.1 模块

一个程序按照其功能做拆分,分成相互独立的模块,以便于每个模块只包含与其功能相关的内容,比如登录模块首页模块等等。

🌀 1.2.2 组件

组件指的是单一的功能组件,如登录组件视频组件支付组件 等,每个组件都可以以一个单独的 module 开发,并且可以单独抽出来作为 SDK 对外发布使用。可以说往往一个模块包含了一个或多个组件。

💥 1.3 组件化的优势

组件化基于可重用的目的,将应用拆分成多个独立组件,以减少耦合:

  • 加快编译速度:每个业务功能都是一个单独的工程,可独立编译运行,拆分后代码量较少,编译自然变快。

  • 解耦:通过关注点分离的形式,将App分离成多个模块,每个模块都是一个组件。

  • 提高开发效率:多人开发中,每个组件模块由单人负责,降低了开发之间沟通的成本,减少因代码风格不一而产生的相互影响。

  • 代码复用:类似我们引用的第三方库,可以将基础组件或功能组件剥离。在新项目微调或直接使用。

💥 1.4 组件化需要解决的问题

  • 组件分层:怎么将一个项目分成多个组件、组件间的依赖关系是怎么样的?

  • 组件单独运行和集成调试:组件是如何独立运行和集成调试的?

  • 组件间通信:主项目与组件、组件与组件之间如何通信就变成关键?

🔥 二、组件分层

组件依赖关系是上层依赖下层,修改频率是上层高于下层。先上一张图:

img

💥 2.1 基础组件

基础公共模块,最底层的库:

  • 封装公用的基础组件;

  • 网络访问框架、图片加载框架等主流的第三方库;

  • 各种第三方SDK。

💥 2.2 common组件(lib_common)

  • 支撑业务组件、功能组件的基础(BaseActivity/BaseFragment等基础能力;

  • 依赖基础组件层;

  • 业务组件、功能组件所需的基础能力只需要依赖common组件即可获得。

💥 2.3 功能组件

  • 依赖基础组件层;

  • 对一些公用的功能业务进行封装与实现;

  • 业务组件可以在library和application之间切换,但是最后打包时必须是library ;

💥 2.4 业务组件

  • 可直接依赖基础组件层;同时也能依赖公用的一些功能组件;

  • 各组件之间不存在依赖关系,通过路由进行通信;

  • 业务组件可以在library和application之间切换,但是最后打包时必须是library ;

💥 2.5 主工程(app)

  • 只依赖各业务组件;

  • 除了一些全局的配置和主Activity之外,不包含任何业务代码,是应用的入口;

💥 2.6 完成后项目

img

这只是个大概,并不是说必须这样,可以按照自己的方式来。比如:你觉得基础组件比较多导致project里面的项目太多,那么你可以创建一个lib_base,然在lib_base里面再创建其他基础组件即可。

🔥 三、组件单独调试

💥 3.1 创建组件(收藏)

img

  • library和application之间切换:选择第一项。

  • 始终是library:选择第二项

这样尽可能的减少变动项,当然这仅仅是个建议,看个人习惯吧。

因为咱们创建的是一个module,所以在AndridManifest中添加android:exported="true"属性可直接构建一个APK。下面咱们看看如何生成不同的工程类型。

💥 3.2 动态配置组件的工程类型

在 AndroidStudio 开发 Android 项目时,使用的是 Gradle 来构建,具体来说使用的是 Android Gradle 插件来构建,Android Gradle 中提供了三种插件,在开发中可以通过配置不同的插件来构建不同的工程。

🌀 3.2.1 build.gradle(module)

//构建后输出一个 APK 安装包
apply plugin: 'com.android.application'
//构建后输出 ARR 包
apply plugin: 'com.android.library'
//配置一个 Android Test 工程
apply plugin: 'com.android.test'

独立调试:设置为 Application 插件。

集成调试:设置为 Library 插件。

🌀 3.2.2 设置gradle.properties

img

isDebug = true 独立调试

🌀 3.2.3 动态配制插件(build.gradle)

//注意gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换
if(isDebug.toBoolean()){
   //构建后输出一个 APK 安装包
   apply plugin: 'com.android.application'
}else{
   //构建后输出 ARR 包
   apply plugin: 'com.android.library'
}

💥 3.3 动态配置组件的 ApplicationId 和 AndroidManifest 文件

  • 一个 APP 是只有一个 ApplicationId ,所以在单独调试集成调试组件的 ApplicationId 应该是不同的。

  • 单独调试时也是需要有一个启动页,当集成调试时主工程和组件的AndroidManifest文件合并会产生多个启动页。

根据上面动态配制插件的经验,我们也需要在build.gradle中动态配制ApplicationId 和 AndroidManifest 文件。

🌀 3.3.1 准备两个不同路径的 AndroidManifest 文件

img

有什么不同?咱们一起看看具体内容。

🌀 3.3.2 src/main/debug/AndroidManifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.scc.module.collect">

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.SccMall">
       <activity android:name=".CollectActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

🌀 3.3.3 src/main/debug/AndroidManifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.scc.module.collect">
   <application
       android:allowBackup="true"
       android:supportsRtl="true"
       >
       <activity android:name=".CollectActivity"/>
   </application>

</manifest>

🌀 3.3.4 动态配制(build.gradle)

defaultConfig {
   if(isDebug.toBoolean()){
       //独立调试的时候才能设置applicationId
       applicationId "com.scc.module.collect"
  }
}
sourceSets {
   main {
       if (isDebug.toBoolean()) {
           //独立调试
           manifest.srcFile 'src/main/debug/AndroidManifest.xml'
      } else {
           //集成调试
           manifest.srcFile 'src/main/AndroidManifest.xml'
      }
  }
}

💥 3.4 实现效果

🌀 3.4.1 独立调试

isDebug = true

img

🌀 3.4.2 集成调试

isDebug = false

img

🔥 四、Gradle配置统一管理

💥 4.1 config.gradle

当我们需要进行插件版本、依赖库版本升级时,项目多的话改起来很麻烦,这时就需要我们对Gradle配置统一管理。如下:

img

具体内容

ext{
   //组件独立调试开关, 每次更改值后要同步工程
   isDebug = true
   android = [
           // 编译 SDK 版本
           compileSdkVersion: 31,
           // 最低兼容 Android 版本
           minSdkVersion   : 21,
           // 最高兼容 Android 版本
           targetSdkVersion : 31,
           // 当前版本编号
           versionCode     : 1,
           // 当前版本信息
           versionName     : "1.0.0"
  ]
   applicationid = [
           app:"com.scc.sccmall",
           main:"com.scc.module.main",
           webview:"com.scc.module.webview",
           login:"com.scc.module.login",
           collect:"com.scc.module.collect"
  ]
   dependencies = [
           "appcompat"         :'androidx.appcompat:appcompat:1.2.0',
           "material"         :'com.google.android.material:material:1.3.0',
           "constraintlayout" :'androidx.constraintlayout:constraintlayout:2.0.1',
           "livedata"         :'androidx.lifecycle:lifecycle-livedata:2.4.0',
           "viewmodel"         :'androidx.lifecycle:lifecycle-viewmodel:2.4.0',
           "legacyv4"         :'androidx.legacy:legacy-support-v4:1.0.0',
           "splashscreen"     :'androidx.core:core-splashscreen:1.0.0-alpha01'
  ]
   libARouter= 'com.alibaba:arouter-api:1.5.2'
   libARouterCompiler = 'com.alibaba:arouter-compiler:1.5.2'
   libGson = 'com.google.code.gson:gson:2.8.9'
}

💥 4.2 添加配制文件build.gradle(project)

apply from:"config.gradle"

💥 4.3 其他组件使用

//build.gradle
//注意gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换
if(isDebug.toBoolean()){
   //构建后输出一个 APK 安装包
   apply plugin: 'com.android.application'
}else{
   //构建后输出 ARR 包
   apply plugin: 'com.android.library'
}
android {
   compileSdkVersion 31

   defaultConfig {
       if(isDebug.toBoolean()){
           //独立调试的时候才能设置applicationId
           applicationId "com.scc.module.collect"
      }
       minSdkVersion 21
       targetSdkVersion 31
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
      }
  }
   sourceSets {
       main {
           if (isDebug.toBoolean()) {
               //独立调试
               manifest.srcFile 'src/main/debug/AndroidManifest.xml'
          } else {
               //集成调试
               manifest.srcFile 'src/main/AndroidManifest.xml'
          }
      }
  }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
  }
}

dependencies {
//   implementation root.dependencies.appcompat
//   implementation root.dependencies.material
//   implementation root.dependencies.constraintlayout
//   implementation root.dependencies.livedata
//   implementation root.dependencies.viewmodel
//   implementation root.dependencies.legacyv4
//   implementation root.dependencies.splashscreen
//   implementation root.libARouter
   //上面内容在lib_common中已经添加咱们直接依赖lib_common
   implementation project(':lib_common')

   testImplementation 'junit:junit:4.+'
   androidTestImplementation 'androidx.test.ext:junit:1.1.2'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

🔥 五、组件间界面跳转(ARouter)

💥 5.1 介绍

Android 中的界面跳转那是相当简单,但是在组件化开发中,由于不同组件式没有相互依赖的,所以不可以直接访问彼此的类,这时候就没办法通过显式的方式实现了。

所以在这里咱们采取更加灵活的一种方式,使用 Alibaba 开源的 ARouter 来实现。

一个用于帮助 Android App 进行组件化改造的框架 —— 支持模块间的路由、通信、解耦

文档介绍的蛮详细的,感兴趣的可以自己实践一下。这里做个简单的使用。

💥 5.2 使用

🌀 5.2.1 添加依赖

先在统一的config.gradle添加版本等信息

ext{
  ...
   libARouter= 'com.alibaba:arouter-api:1.5.2'
   libARouterCompiler = 'com.alibaba:arouter-compiler:1.5.2'
}

因为所有的功能组件和业务组件都依赖lib_common,那么咱们先从lib_common开始配制

lib_common

dependencies {
   api root.libARouter
  ...
}

其他组件(如collect)

android {
   defaultConfig {
      ...
       javaCompileOptions {
           annotationProcessorOptions {
               arguments = [AROUTER_MODULE_NAME: project.getName()]
               //如果项目内有多个annotationProcessor,则修改为以下设置
               //arguments += [AROUTER_MODULE_NAME: project.getName()]
          }
      }
  }
}

dependencies {
   //arouter-compiler的注解依赖需要所有使用 ARouter 的 module 都添加依赖
   annotationProcessor root.libARouterCompiler
  ...
}

🌀 5.2.2 添加注解

你要跳转的Activity

// 在支持路由的页面上添加注解(必选)
// 这里的路径需要注意的是至少需要有两级,/xx/xx
@Route(path = "/collect/CollectActivity")
public class CollectActivity extends AppCompatActivity {
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_collect);
  }
}

🌀 5.2.3 初始化SDK(主项目Application)

public class App extends BaseApplication {
   @Override
   public void onCreate() {
       super.onCreate();
       if (isDebug()) {           // 这两行必须写在init之前,否则这些配置在init过程中将无效
           ARouter.openLog();     // 打印日志
           ARouter.openDebug();   // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
      }
       ARouter.init(this); // 尽可能早,推荐在Application中初始化
  }
   private boolean isDebug() {
       return BuildConfig.DEBUG;
  }
}

💥 5.3 发起路由操作

🌀 5.3.1 应用内简单的跳转

ARouter.getInstance().build("/collect/CollectActivity").navigation();

这里是用module_main的HomeFragment跳转至module_collect的CollectActivity界面,两个module中不存在依赖关系。"/collect/CollectActivity"在上面已注册就不多描述了。

效果如下:

img

🌀 5.3.2 跳转并携带参数

这里是用module_main的MineFragment的Adapter跳转至module_webview的WebViewActivity界面,两个module中同样不存在依赖关系。

启动方

ARouter.getInstance().build("/webview/WebViewActivity")
  .withString("url", bean.getUrl())
  .withString("content",bean.getName())
  .navigation();

这里传了两个参数urlname到WebViewActivity,下面咱们看看WebViewActivity怎么接收。

接收方

//为每一个参数声明一个字段,并使用 @Autowired 标注
//URL中不能传递Parcelable类型数据,通过ARouter api可以传递Parcelable对象
//添加注解(必选)
@Route(path = "/webview/WebViewActivity")
public class WebViewActivity extends BaseActivity<ActivityWebviewBinding, WebViewViewModel> {
   //发送方和接收方定义的key名称相同则无需处理
   @Autowired
   public String url;
   //通过name来映射URL中的不同参数
   //发送方定义key为content,我们用title来接收
   @Autowired(name = "content")
   public String title;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       //注入参数和服务(这里用到@Autowired所以要设置)
       //不使用自动注入,可不写,如CollectActivity没接收参数就没有设置
       ARouter.getInstance().inject(this);
       binding.btnBoom.setText(String.format("%s,你来啦", title));
       //加载链接
       initWebView(binding.wbAbout, url);
  }
}

上效果图:

搞定,更多高级玩法可自行探索。

🌀 5.3.3 小记(ARouter目标不存在)

W/ARouter::: ARouter::There is no route match the path

这里出现个小问题,配置注释都好好的,但是发送发无论如何都找不到设置好的Activity。尝试方案:

  • Clean Project

  • Rebuild Project

  • 在下图也能找到ARouter内容。

后来修改Activity名称好了。

img

🔥 六、组件间通信(数据传递)

界面跳转搞定了,那么数据传递怎么办,我在module_main中使用悬浮窗,但是需要判断这个用户是否已登录,再执行后续逻辑,这个要怎么办?这里我们可以采用 接口 + ARouter 的方式来解决。

在这里可以添加一个 componentbase 模块,这个模块被所有的组件依赖

这里我们通过 module_main组件 中调用 module_login组件 中的方法来获取登录状态这个场景来演示。

💥 6.1 通过依赖注入解耦:服务管理(一) 暴露服务

🌀 6.1.1 创建 componentbase 模块(lib)

img

🌀 6.1.2 创建接口并继承IProvider

注意:接口必须继承IProvider,是为了使用ARouter的实现注入。

img

🌀 6.1.3 在module_login组件中实现接口

lib_common

所有业务组件和功能组件都依赖lib_common,所以咱们直接在lib_common添加依赖即可

dependencies {
  ...
   api project(":lib_componentbase")
}

module_login

dependencies {
  ...
   implementation project(':lib_common')
}

实现接口

//实现接口
@Route(path = "/login/AccountServiceImpl")
public class AccountServiceImpl implements IAccountService {
   @Override
   public boolean isLogin() {
       MLog.e("AccountServiceImpl.isLogin");
       return true;
  }

   @Override
   public String getAccountId() {
       MLog.e("AccountServiceImpl.getAccountId");
       return "1000";
  }

   @Override
   public void init(Context context) {

  }
}

img

💥 6.2 通过依赖注入解耦:服务管理(二) 发现服务

🌀 6.2.1 在module_main中调用调用是否已登入

public class HomeFragment extends BaseFragment<FragmentHomeBinding> {
   @Autowired
   IAccountService accountService;
   @Override
   public void onViewCreated(@NonNull @NotNull View view, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onViewCreated(view, savedInstanceState);
       ARouter.getInstance().inject(this);
       binding.frgmentHomeFab.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               MLog.e("Login:"+accountService.isLogin());
               MLog.e("AccountId:"+accountService.getAccountId());

          }
      });
  }
}

img 运行结果:

E/-SCC-: AccountServiceImpl.isLogin
E/-SCC-: Login:true
E/-SCC-: AccountServiceImpl.getAccountId
E/-SCC-: AccountId:1000

🔥 七、总结

本文介绍了组件化、组件分层、解决了组件的独立调试、集成调试、页面跳转、组件通信等。

其实会了这些后你基本可以搭建自己的组件化项目了。其实最大的问题还是分组分层、组件划分。这个就需要根据你的实际情况来设置。

本项目比较糙,后面会慢慢完善。比如添加Gilde、添加MMVK、添加Room等。

项目传送门

💥 相关推荐

Android OkHttp+Retrofit+Rxjava+Hilt实现网络请求框架

💥 参考与感谢

“终于懂了” 系列:Android组件化,全面掌握!

Android 组件化最佳实践

手把手带你搭建一个优秀的Android项目架构


作者:Android帅次
来源:https://juejin.cn/post/7033954652315975688


收起阅读 »

就业寒冬,从拉勾招聘看Python就业前景

事情的起源是这样的,某个风和日丽的下午... 习惯性的打开知乎准备划下水,看到一个问题刚好邀请回答于是就萌生了采集下某招聘网站Python岗位招聘的信息,看一下目前的薪水和岗位分布,说干就干。Chrome浏览器右键检查查看network,找到链接https:/...
继续阅读 »



1.数据采集

事情的起源是这样的,某个风和日丽的下午... 习惯性的打开知乎准备划下水,看到一个问题刚好邀请回答

img

于是就萌生了采集下某招聘网站Python岗位招聘的信息,看一下目前的薪水和岗位分布,说干就干。

先说下数据采集过程中遇到的问题,首先请求头是一定要伪装的,否则第一步就会给你弹出你的请求太频繁,请稍后再试,其次网站具有多重反爬策略,解决方案是每次先获取session然后更新我们的session进行抓取,最后拿到了想要的数据。

Chrome浏览器右键检查查看network,找到链接https://www.lagou.com/jobs/positionAjax.json?needAddtionalResult=false

img

可以看到返回的数据正是页面的Python招聘详情,于是我直接打开发现直接提示{"status":false,"msg":"您操作太频繁,请稍后再访问","clientIp":"124.77.161.207","state":2402},机智的我察觉到事情并没有那么简单

img

真正的较量才刚刚开始,我们先来分析下请求的报文,

img

img

可以看到请求是以post的方式传递的,同时传递了参数

datas = {
          'first': 'false',
          'pn': x,
          'kd': 'python',
      }

同时不难发现每次点击下一页都会同时发送一条get请求

这里我点了两次,出现两条get请求

img

经过探索,发现这个get请求和我们post请求是一致的,那么问题就简单许多,整理一下思路

img

关键词:python 搜索范围:全国 数据时效:2019.05.05

#!/usr/bin/env python3.4
# encoding: utf-8
"""
Created on 19-5-05
@title: ''
@author: Xusl
"""
import json
import requests
import xlwt
import time

# 获取存储职位信息的json对象,遍历获得公司名、福利待遇、工作地点、学历要求、工作类型、发布时间、职位名称、薪资、工作年限
def get_json(url, datas):
  my_headers = {
      "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36",
      "Referer": "https://www.lagou.com/jobs/list_Python?city=%E5%85%A8%E5%9B%BD&cl=false&fromSearch=true&labelWords=&suginput=",
      "Content-Type": "application/x-www-form-urlencoded;charset = UTF-8"
  }
  time.sleep(5)
  ses = requests.session()   # 获取session
  ses.headers.update(my_headers) # 更新
  ses.get("https://www.lagou.com/jobs/list_python?city=%E5%85%A8%E5%9B%BD&cl=false&fromSearch=true&labelWords=&suginput=")
  content = ses.post(url=url, data=datas)
  result = content.json()
  info = result['content']['positionResult']['result']
  info_list = []
  for job in info:
      information = []
      information.append(job['positionId']) # 岗位对应ID
      information.append(job['city']) # 岗位对应城市
      information.append(job['companyFullName']) # 公司全名
      information.append(job['companyLabelList']) # 福利待遇
      information.append(job['district']) # 工作地点
      information.append(job['education']) # 学历要求
      information.append(job['firstType']) # 工作类型
      information.append(job['formatCreateTime']) # 发布时间
      information.append(job['positionName']) # 职位名称
      information.append(job['salary']) # 薪资
      information.append(job['workYear']) # 工作年限
      info_list.append(information)
      # 将列表对象进行json格式的编码转换,其中indent参数设置缩进值为2
      # print(json.dumps(info_list, ensure_ascii=False, indent=2))
  # print(info_list)
  return info_list

def main():
  page = int(input('请输入你要抓取的页码总数:'))
  # kd = input('请输入你要抓取的职位关键字:')
  # city = input('请输入你要抓取的城市:')

  info_result = []
  title = ['岗位id', '城市', '公司全名', '福利待遇', '工作地点', '学历要求', '工作类型', '发布时间', '职位名称', '薪资', '工作年限']
  info_result.append(title)
  for x in range(1, page+1):
      url = 'https://www.lagou.com/jobs/positionAjax.json?needAddtionalResult=false'
      datas = {
          'first': 'false',
          'pn': x,
          'kd': 'python',
      }
      try:
          info = get_json(url, datas)
          info_result = info_result + info
          print("第%s页正常采集" % x)
      except Exception as msg:
          print("第%s页出现问题" % x)
       
      # 创建workbook,即excel
      workbook = xlwt.Workbook(encoding='utf-8')
      # 创建表,第二参数用于确认同一个cell单元是否可以重设值
      worksheet = workbook.add_sheet('lagouzp', cell_overwrite_ok=True)
      for i, row in enumerate(info_result):
          # print(row)
          for j, col in enumerate(row):
              # print(col)
              worksheet.write(i, j, col)
      workbook.save('lagouzp.xls')

if __name__ == '__main__':
  main()

日志记录

img

当然存储于excel当然是不够的,之前一直用matplotlib做数据可视化,这次换个新东西pyecharts

2.了解pyecharts

pyecharts是一款将python与echarts结合的强大的数据可视化工具,包含多种图表

  • Bar(柱状图/条形图)

  • Bar3D(3D 柱状图)

  • Boxplot(箱形图)

  • EffectScatter(带有涟漪特效动画的散点图)

  • Funnel(漏斗图)

  • Gauge(仪表盘)

  • Geo(地理坐标系)

  • Graph(关系图)

  • HeatMap(热力图)

  • Kline(K线图)

  • Line(折线/面积图)

  • Line3D(3D 折线图)

  • Liquid(水球图)

  • Map(地图)

  • Parallel(平行坐标系)

  • Pie(饼图)

  • Polar(极坐标系)

  • Radar(雷达图)

  • Sankey(桑基图)

  • Scatter(散点图)

  • Scatter3D(3D 散点图)

  • ThemeRiver(主题河流图)

  • WordCloud(词云图)

用户自定义

  • Grid 类:并行显示多张图

  • Overlap 类:结合不同类型图表叠加画在同张图上

  • Page 类:同一网页按顺序展示多图

  • Timeline 类:提供时间线轮播多张图

另外需要注意的是从版本0.3.2 开始,为了缩减项目本身的体积以及维持 pyecharts 项目的轻量化运行,pyecharts 将不再自带地图 js 文件。如用户需要用到地图图表(Geo、Map),可自行安装对应的地图文件包。

  1. 全球国家地图: echarts-countries-pypkg (1.9MB): 世界地图和 213 个国家,包括中国地图

  2. 中国省级地图: echarts-china-provinces-pypkg (730KB):23 个省,5 个自治区

  3. 中国市级地图: echarts-china-cities-pypkg (3.8MB):370 个中国城市

也可以使用命令进行安装

pip install echarts-countries-pypkg
pip install echarts-china-provinces-pypkg
pip install echarts-china-cities-pypkg

3.数据可视化(代码+展示)

  • 各城市招聘数量

from pyecharts import Bar

city_nms_top10 = ['北京', '上海', '深圳', '成都', '杭州', '广州', '武汉', '南京', '苏州', '郑州', '天津', '西安', '东莞', '珠海', '合肥', '厦门', '宁波','南宁', '重庆', '佛山', '大连', '哈尔滨', '长沙', '福州', '中山']
city_nums_top10 = [149, 95, 77, 22, 17, 17, 16, 13, 7, 5, 4, 4, 3, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1]

bar = Bar("Python岗位", "各城市数量")
bar.add("数量", city_nms, city_nums, is_more_utils=True)
# bar.print_echarts_options() # 该行只为了打印配置项,方便调试时使用
bar.render('Python岗位各城市数量.html')  # 生成本地 HTML 文件

img

  • 地图分布展示(这个场景意义不大,不过多分析)

from pyecharts import Geo

city_datas = [('北京', 149), ('上海', 95), ('深圳', 77), ('成都', 22), ('杭州', 17), ('广州', 17), ('武汉', 16), ('南京', 13), ('苏州', 7), ('郑州', 5), ('天津', 4), ('西安', 4), ('东莞', 3), ('珠海', 2), ('合肥', 2), ('厦门', 2), ('宁波', 1), ('南宁', 1), ('重庆', 1), ('佛山', 1), ('大连', 1), ('哈尔滨', 1), ('长沙', 1), ('福州', 1), ('中山', 1)]
geo = Geo("Python岗位城市分布地图", "数据来源拉勾", title_color="#fff",
          title_pos="center", width=1200,
          height=600, background_color='#404a59')
attr, value = geo.cast(city_datas)
geo.add("", attr, value, visual_range=[0, 200], visual_text_color="#fff",symbol_size=15, is_visualmap=True)
geo.render("Python岗位城市分布地图_scatter.html")
geo = Geo("Python岗位城市分布地图", "数据来源拉勾", title_color="#fff",
          title_pos="center", width=1200,
          height=600, background_color='#404a59')
attr, value = geo.cast(city_datas)
geo.add("", attr, value, type="heatmap", visual_range=[0,10],visual_text_color="#fff",symbol_size=15,is_visualmap=True)
geo.render("Python岗位城市分布地图_heatmap.html")

img

img

  • 各个城市招聘情况

from pyecharts import Pie

city_nms_top10 = ['北京', '上海', '深圳', '成都', '广州', '杭州', '武汉', '南京', '苏州', '郑州']
city_nums_top10 = [149, 95, 77, 22, 17, 17, 16, 13, 7, 5]
pie = Pie()
pie.add("", city_nms_top10, city_nums_top10, is_label_show=True)
# pie.show_config()
pie.render('Python岗位各城市分布饼图.html')

img

北上深的岗位明显碾压其它城市,这也反映出为什么越来越多的it从业人员毕业以后相继奔赴一线城市,除了一线城市的薪资高于二三线这个因素外,还有一个最重要的原因供需关系,因为一线岗位多,可选择性也就比较高,反观二三线的局面,很有可能你跳个几次槽,发现同行业能呆的公司都待过了...

  • 薪资范围

    img

由此可见,python的岗位薪资多数在10k~20k,想从事Python行业的可以把工作年限和薪资结合起来参考一下。

  • 学历要求 + 工作年限

    img

从工作年限来看,1-3年或者3-5年工作经验的招聘比较多,而应届生和一年以下的寥寥无几,对实习生实在不太友好,学历也普遍要求本科,多数公司都很重视入职人员学历这点毋容置疑,虽然学历不代表一切,但是对于一个企业来说,想要短时间内判断一个人的能力,最快速有效的方法无疑是从学历入手。学历第一关,面试第二关。

但是,这不代表学历不高的人就没有好的出路,现在的大学生越来越多,找工作也越来越难,竞争越来越激烈,即使具备高学历,也不能保证你一定可以找到满意的工作,天道酬勤,特别是it这个行业,知识的迭代,比其他行业来的更频密。不断学习,拓展自己学习的广度和深度,才是最正确的决定。

就业寒冬来临,我们需要的是理性客观的看待,而不是盲目地悲观或乐观。从以上数据分析,如果爱好Python,仍旧可以入坑,不过要注意一个标签有工作经验,就算没有工作经验,自己在学习Python的过程中一定要尝试独立去做一个完整的项目,爬虫也好,数据分析也好,亦或者是开发,都要尝试独立去做一套系统,在这个过程中培养自己思考和解决问题的能力。持续不断的学习,才是对自己未来最好的投资,也是度过寒冬最正确的姿势。


作者:一只写程序的猿
来源:https://juejin.cn/post/6844903837698883597

收起阅读 »

环信广纳人才,base北京,欢迎大家踊跃跳槽/推荐~~ps:双休不加班

1、高级Android开发工程师:1. 3年及以上Android开发经验,具有成熟Android APP产品开发经验者优先;2. 熟练掌握Android SDK,Java,设计模式,http,多线程编程者优先;3. 有NDK开发经验优先;4. 熟悉Androi...
继续阅读 »

1、高级Android开发工程师:
1. 3年及以上Android开发经验,具有成熟Android APP产品开发经验者优先;
2. 熟练掌握Android SDK,Java,设计模式,http,多线程编程者优先;
3. 有NDK开发经验优先;
4. 熟悉Android Framwork,插件开发,有APP架构设计优先;
5. 有SDK开发经验优先;6.有IM开发经验优先


2、iOS开发工程师:
1. 3年及以上iOS开发经验,具有成熟iOS APP产品开发经验者优先;
2. 熟悉iOS框架以及各种特性,深刻理解常用设计模式, 熟练使用网络、多线程、数据库等客户端开发技术;
3. 扎实的Objective-C或Swift语言基础;
4. 分析问题和解决问题的能力强,有大规模代码的阅读和修改经验者优先;
5. 有较好的学习能力和沟通能力,有创新能力和责任感,对移动端产品有浓厚的兴趣;

3、前端工程师:
1. 计算机及相关专业本科及以上学历,至少3年以上前端开发工作经验;
2. 有丰富的Web前端开发经验,熟悉HTML5开发,浏览器渲染原理,熟悉React框架;
3. 工作认真负责,乐观开朗,有较强的逻辑分析、问题排查能力,善于团队合作;
4. 良好软件工程思想,良好的编程能力和编程习惯;
5. 熟悉HTTP、WebSocket等协议;
6. 有针对海外开发者产品经验的优先考虑;
7. 有SDK开发经验者优先考虑;

4、高级SDK跨平台开发工程师(Flutter/Electron/RN/Unity/Unreal):
1. 熟练使用 Java script/C#/Dart 其中至少一种以上开发语言。
2. 熟悉使用 C++,有多语言混合开发经验。
3. 有过 Android/iOS/Windows/macOS 其中至少一种原生平台应用的开发经验。
4. 使用过跨平台框架,有框架和原生混合开发经验。例如:Electron/ Unity/Flutter 其中的一种或者多种。
5. 有即时通讯相关的开发经验属于加分项。
6. 有跨平台框架的插件,中间件或者 SDK 开发经验属于加分项。
7. 本科及以上学历, 有两年以上的工作经验。

5、中高级后台工程师(Erlang/Go/C++):
1. 3年以上软件工程师工作经验,有Erlang,Go,C++经验或感兴趣优先;
2. 大型通讯软件,通讯协议开发经验优先;
3. 计算机科学、自动化、通讯等相关专业本科以上学历;
4. 熟悉TCP/IP,HTTP、WebSocket协议;
5. 熟悉SQL、Kafka、Redis;6.熟悉Linux操作系统;

6、中高级Java工程师:
1. 3年以上大型互联网分布式产品或网络软件设计经验;
2. 强大的需求分析能力与编码能力;
3. 精通Java语言,精通异步编程、多线程编程;
4. 精通Spring、Spring Boot、Sp;

7、产品经理:
1. 5年以上to B产品规划与设计经验,3年及以上互联网产品工作经验,计算机或相关专业本科以上学历,有IaaS、Paas、中台方向相关经验者优先;
2. 具备良好的需求分析和产品设计能力,熟练使用Axure/Sketch等产品原型工具;
3. 具备丰富的PaaS平台相关经验,理解PaaS、SaaS等架构逻辑,对业界领先的云平台有一定研究;
4. 对产品的发展趋势有敏锐的洞察力和创新意识,重视细节与用户体验,对用户使用流程、交互流程敏感;
5. 2年以上B端产品经验;6.有研发经验可以作为加分项;

8、初级测试和高级自动化测试工程师:
1. 计算机本科及以上学历;
2. 2年以上自动化测试经验;
3. 熟悉mysql或者相关数据库,能熟练编写sql脚本优先;
4. 熟悉Java\\Python和Linux操作系统;
5. 熟悉自动化测试,熟悉robot framework\\Appium\\Selenium等测试框架,有多个大型实际项目自动化测试经验者优先;
6. 良好的表达沟通能力、细致、责任心、团队精神;7.具备追求卓越的质量观念,并有志成为测试领域的高端人才。

9.运维工程师:
1. 有3年以上互联网系统运维工作经验。
2. 熟悉Linux操作系统的管理维护(部署、配置和调优),熟练使用Linux Shell或Python或者golang编程语言。
3. 熟悉Docker以及相关容器技术,使用过docker-compose、mesos 等容器编排工具,进行过开发维护经验者优先。
4. 熟悉Prometheus等常用监控软件。
5. 熟练使用nginx, redis, kafka 等常用软件,并进行调优。
6. 熟悉TiDB或者CockroachDB优先。
7. 热爱运维工作,能承受压力,具备较强的问题分析和解决能力。
8. 积极主动、责任心强,良好的沟通能力和团队协作精神。


10、高级产品市场经理
岗位职责:
1.负责环信即时通讯云的价值挖掘、内容组织并推动将产品及价值传递给用户,主要产出物为:产品技术及解决方案干货内容;客户案例;产品彩页;产品PPT;市场软文;市场活动演讲PPT;官网内容;市场活动产品资料等
2.行业研究,分析行业及竞争对手动向,为产品团队提出产品定位建议
3.做为公司产品对外接口, 不定期参加公司对外直播公开课演讲以及行业会议演讲


任职资格:
1.有技术背景,懂得如何与研发团队、销售及用户沟通和协作;
2.熟悉通讯云领域SaaS、PaaS产品,理解并挖掘相关产品核心价值;
3.沟通能力强,写作能力强;
4.有IM通讯云领域市场分析,行业分析,顾问咨询经验者优先;
5.有产品经理或项目经理经验者优先;
6.本科以上学历;
7.5年以上工作经验;

--------------------------------

以上岗位如有意向,可私信我,咱们内推搞起来!

成功推荐朋友入职可获奖励5000~20000元,欢迎大家踊跃推荐!

收起阅读 »

iOS集成

IM 和 客服 并存开发指南—iOS篇 ...
继续阅读 »




IM 和 客服 并存开发指南—iOS篇











 如果觉得哪里描述的不清晰,可评论内指出,会不定期更新。


 一、SDK 介绍

      HelpDesk.framework 为 客服SDK(带实时音视频)

      HelpDeskLite.framework 为 客服SDK(不带实时音视频)

      Hyphenate.framework 为 IM SDK(带实时音视频)

      HyphenateLite.framework 为 IM SDK(不带实时音视频)

      环信客服SDK 基于 IM SDK 3.x , 如果同时集成 客服 和 IM,只需要在初始化、登录、登出操作时使用客服SDK 提供的相应API,IM 的其他API均不受影响。

      UI 部分集成需要分别导入 HelpDeskUI 和 IM demo 中的UI文件(也可以自定义UI)。 下面详细介绍IM 和 客服共存的开发步骤。

二、注意事项

      1、开发过程中,初始化、登录和登出,务必只使用客服访客端SDK的API。

      2、需要联系商务开通客服长连接。

           不开通长连接,会出现用户长时间(一天或几天)不使用app,再打开app会无法正常使用im相关功能的问题,报错信息一般是User is not login。

      3、IM SDK 和客服SDK 都包括了模拟器的CPU 架构,在上传到app store时需要剔除模拟器的CPU 架构,保留  armv7、arm64,参考文档:上传appstore以及打包ipa注意事项。 

三、资源准备

      到环信官网下载客服访客端的开源的商城Demo源码 + SDK,下载链接:http://www.easemob.com/download/cs  选  择“iOS SDK”下载(如下图)。

      

下载客服.png



      到环信官网下载IM的开源的Demo源码 + SDK ,下载链接:http://www.easemob.com/download/im 选择 iOS SDK(如下图)。

      

下载IM.png




下载的 IM SDK+Demo 和 客服SDK+Demo 中都有 IM 的
Hyphenate.framework 或 HyphenateLite.framework,为了保持版本的匹配,我们只使用 IM Demo 中的
UI, 而不使用 IM SDK 中 的 Hyphenate.framework 或 HyphenateLite.framework 文件。

四、集成步骤

      1、阅读客服访客端SDK集成文档,集成客服,地址:http://docs.easemob.com/cs/300visitoraccess/iossdk。 

      2、阅读 IM 的集成文档,地址:http://docs-im.easemob.com/im/ios/sdk/prepare 

      3、将 IM Demo 中的 UI 文件按照自己的需求分模块导入到工程中

      4、将 IM 的 UI 所依赖的第三方库集成到项目中(IM集成文档内有说明)

      5、在pch文件中引入 EMHeaders.h 

          #ifdef __OBJC__ 

            //包含实时音视频功能 

            #import  

            // 若不包含实时音视频,则替换为 

            // #import  

            #import "HelpDeskUI.h" 

            #import "EMHeaders.h" 

         #endif

      6、由于HelpDeskUI 和 IM UI 中都使用了 第三方库,如果工程中出现三方库重复的问题,可将重复文件删除,如果部分接口已经升级或弃用可自行升级、调整。

提供的兼容Demo介绍:

     1、Demo集成了初始化sdk、登录、退出登录、IM单聊、联系客服的简单功能,处理了第三方库冲突的问题。

     2、pch文件中的appkey等信息需要换成开发者自己的。

     3、Demo源码下载地址: https://pan.baidu.com/s/1v1TUl-fqJNLQrtsJfWYGzw 

         提取码: kukb 
收起阅读 »

ios客服云集成常见报错

注意:向自己工程中添加环信SDK和UI文件的时候,不要直接向xcode中拖拽添加,先把SDK和UI文件粘贴到自己工程的finder目录中,再从finder中向xcode中拖拽添加,避免出现找不到SDK或者UI文件的情况。   1、很多同学在首次“导入...
继续阅读 »
注意:向自己工程中添加环信SDK和UI文件的时候,不要直接向xcode中拖拽添加,先把SDK和UI文件粘贴到自己工程的finder目录中,再从finder中向xcode中拖拽添加,避免出现找不到SDK或者UI文件的情况。

 

1、很多同学在首次“导入SDK”或“更新SDK重新导入SDK”后,Xcode运行报以下的error:

dyld: Library not loaded: @rpath/Hyphenate.framework/Hyphenate

  Referenced from:
/Users/shenchong/Library/Developer/CoreSimulator/Devices/C768FE68-6E79-40C8-8AD1-FFFC434D51A9/data/Containers/Bundle/Application/41EA9A48-4DD5-4AA4-AB3F-139CFE036532/CallBackTest.app/CallBackTest

  Reason: image not found

       这个原因是工程未加载到 framework,正确的处理方式是在TARGETS → General → Embedded
Binaries 中添加HelpDesk.framework和Hyphenate.framework依赖库,且 Linked
Frameworks and Libraries中依赖库的Status必须是Required。

1访客端_image_not_found.png



 

2、运行之后,自变量为nil,这就有可能是因为上面所说的依赖库的status设置为了Optional,需要改成Required。

2访客端自变量为nil.png



 

3、打包后上传到appstore报错

(1)ERROR ITMS-90535: "Unexpected CFBundleExecutable Key. The bundle at
'Payload/toy.app/HelpDeskUIResource.bundle' does not contain a bundle
executable. If this bundle intentionally does not contain an executable,
consider removing the CFBundleExecutable key from its Info.plist and
using a CFBundlePackageType of BNDL. If this bundle is part of a
third-party framework, consider contacting the developer of the
framework for an update to address this issue."

方法:把HelpDeskUIResource.bundle里的Info.plist删掉就即可。

3访客端打包90535.png



(2)This bundle is invalid. The value for key CFBundleShortVersionString
‘1.2.2.1’in the Info.plist must be a period-separated list of at most
three non-negative integers. 

4访客端打包90060.png



把sdk里的plist文件的版本号改成3位数即可

5访客端打包1.2_.2_.1位置_.png



(3)Invalid Mach-O Format.The Mach-O in bundle
“SMYG.app/Frameworks/Hyphenate.framework” isn’t consistent with the
Mach-O in the main bundle.The main bundle Mach-O contains armv7(bitcode)
and arm64(bitcode),while the nested bundle Mach-O contains
armv7(machine code) and arm64(machine code).Verify that all of the
targets for a platform have a consistent value for the ENABLE_BITCODE
build setting.”

6访客端打包90636.png



将TARGETS-Build Settings-Enable Bitcode改为NO

7访客端打包bitcode改为NO.png



(4)还有很多同学打包失败,看不出什么原因

8访客端打包需剔除.png



那么可以先看看有没有按照文档剔除x86_64 i386两个平台

文档链接:http://docs.easemob.com/cs/300visitoraccess/iossdk#%E4%B8%8A%E4%BC%A0appstore%E4%BB%A5%E5%8F%8A%E6%89%93%E5%8C%85ipa%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9

 

4、那么剔除x86_64 i386时会遇到can't open input file的错误,这是因为cd的路径错误,把“/HelpDesk.framework”删掉。是cd到framework所在的路径,不是cd到framework

9访客端剔除cd错误.png



 

5、下图中的报错,需要创建一个pch文件,并且在pch文件添加如下判断,将环信的和自己的头文件都引入到#ifdef内部,参考文档:iOS访客端sdk集成准备工作

   #ifdef __OBJC__

   #endif

(swift项目也需这样操作)

10pch加判断1.png



11pch加判断2.png



pch加判断3.png




6、集成环信HelpDeskUI的时候,由于HelpDeskUI内部使用了第三方库,如果与开发者第三方库产生冲突,可将HelpDeskUI中冲突的第三方库删除,如果第三方库中的接口有升级的部分,请酌情进行升级。

12第三方库冲突.png



 

7、集成1.2.2版本demo中的HelpDeskUI,Masonry报错:Passing ‘CGFloat’(aka ‘double’) to parameter of incompatible type ‘__strong id’

需要在pch中添加#define MAS_SHORTHAND_GLOBALS

注意:要在#import "Masonry.h"之前添加此宏定义

13访客端Masonry报错.png



 

8、Xcode11运行demo,PSTCollectionView第三方库会有如下报错

iOS13中PSTCollectionView报错.png



标明下类型就行了,如图

iOS13中PSTCollectionView报错1.png



 

9、Xcode12.3编译报错(Building for iOS, but the linked and embedded framework......)

xcode12.3报错1_.jpg
解决方案:
e1d64313718a467a6bc19b70fadd4543.png
 或者打开xcode,左上方点击File --- Workspace Settings,按照截图修改试下(不建议)

xcode12.3报错2_.jpg
收起阅读 »

ios客服云集成常见问题

1、UI上很多地方显示英文,比如聊天页面的工具栏 把客服demo中配置的国际化文件添加到您自己的工程中。拖之前要打开国际化文件,全部选中这三个,再进行拖入。   2、进入聊天页面没有加载聊天记录 这种情况一般出现在只使用了 HDMessageView...
继续阅读 »




1、UI上很多地方显示英文,比如聊天页面的工具栏

显示英文1.png



把客服demo中配置的国际化文件添加到您自己的工程中。拖之前要打开国际化文件,全部选中这三个,再进行拖入。

显示英文2.png



 

2、进入聊天页面没有加载聊天记录

这种情况一般出现在只使用了 HDMessageViewController 没有使用 HDChatViewController 的时候

在HDMessageViewController 的 viewDidLoad 方法中, 将 [self
tableViewDidTriggerHeaderRefresh]; 的注释打开,再在这句代码之前加上
self.showRefreshHeader = YES; 

 

3、发送表情却显示字符串

访客端表情符号.png



把下面这段代码添加到appdelegate中就可以了

[[HDEmotionEscape sharedInstance] setEaseEmotionEscapePattern:@"\\[[^\\[\\]]{1,3}\\]"];

[[HDEmotionEscape sharedInstance] setEaseEmotionEscapeDictionary:[HDConvertToCommonEmoticonsHelper emotionsDictionary]];

 

4、文本消息,收发双方的布局不一样,如图

文本消息布局错误1.png



参考一下截图修改即可

文本消息布局错误2.png




5、客服能收到访客的消息,访客收不到客服的消息

(1)客服和im同时使用的话,初始化sdk、登录、登出用的是im的api会出现这种情况。必须使用客服的api。

(2)IM sdk升级为客服sdk,不兼容导致的,这种情况可以线上发起会话咨询。

      
6、发送的消息,出现在聊天页面的左侧

一般是由于当前访客没有登录或者登录失败,断点仔细检查下。

7、修改聊天页面导航栏标题
修改_title的值

ff6ff7a40cfea125e0d59e70efb131b8.png









收起阅读 »

接手一个不合格的业务线代码,我是如何去维护以及重构的

iOS
项目背景IM聊天功能作为整个产品业务功能的补充和重要支撑,相信很多的App都会集成这么一个业务功能在,很多App的的IM功能相信都是集成的第三方提供的的SDK服务。相信作为产品业务的有力支撑,IM的消息对于各个公司来说都有不同的业务需求,也就是说普通的图片、文...
继续阅读 »

项目背景

IM聊天功能作为整个产品业务功能的补充和重要支撑,相信很多的App都会集成这么一个业务功能在,很多App的的IM功能相信都是集成的第三方提供的的SDK服务。

相信作为产品业务的有力支撑,IM的消息对于各个公司来说都有不同的业务需求,也就是说普通的图片、文字、红包甚至语音这种常用的消息类型并不能有力支撑起一个IM的业务,今天说的这个的IM业务功能正式在这种背景下。

曾经接手了一个IM模块业务功能,刚开始是起因于解决线上的一个bug,于是开始梳理了一下代码逻辑,于是。。。懵逼了好久好久好久。 虽然IM的代码已经在线上跑了很久了,在我开始解决bug之前貌似有大概有大半年将近一年的的时间少有人来维护,从架构设计上、业务代码实现等点来看,可维护性不高。

梳理代码

这个IM的架构设计大概是在几年以前,长连接协议使用的是WebSocket,业务逻辑有一些复杂,目测从数据逻辑到业务逻辑再到UI逻辑,代码量可能会接近5w这个量级,所以说一开始就一行一行的看代码逻辑显然是不太理智的。

梳理第一步

第一步的主要目的是熟悉代码的主要脉络,于是我开始有序的梳理沿着数据流向梳理主干,要点如下:

  • 分析各个数据模型(model)。
  • 整理各个HTTP请求的API。
  • 整理并备注各个Notification的Key,并标记使用场景。
  • 整理各个Delegate回调函数的使用场景。
  • 整理并备注各个枚举值的含义以及使用场景。

因为此部分项目代码开发周期很久了且开发维护人员换了好几茬之后,代码量大且逻辑比较混乱,在一开始梳理的时候大部分时间都花在了备注各种代码上。

梳理第二步

第一步之后,我其实已经对于IM的架构逻辑开始有了一个初步的比较宽泛的了解。第二步的的主要目的是整理在使用的主要的几个组件:

  • 数据库的初始化创建以及使用逻辑。
  • HTTP代码的初始化创建以及使用逻辑。
  • 长连接代码的初始化创建以及使用逻辑,特别是与服务端沟通和保活的部分。
  • 针对以上基础控件的二次封装控件的整理。

梳理了上面几个之后,我陆续整理了如下:

  • 数据库的表结构设计、初始化创建以及销毁等逻辑。
  • 了解HTTP的接口功能,进一步了解了基础的业务设计。
  • 通过和服务端的同事沟通,明确了在当前的长连接协议下,两端是如何保活、沟通数据以及沟通各种状态的
  • 各个API的使用场景以及使用逻辑。

梳理第三步

上面两步,基本上把最核心的工具整理完成,下面就开始将代码逻辑串起来,整理业务逻辑:

  • 群聊的收发消息逻辑。
  • 私聊的收发消息逻辑。
  • 登录、退出登录、更换账号登录以及绑定账号之后的业务逻辑。
  • 自后台唤醒之后重连以及获取最新消息等的业务逻辑。
  • 收到推送消息之后的业务逻辑。

整理好了以上之后,我利用流程图工具ProcessOn创建了大概有10张左右的流程表,数据流向和业务逻辑一清二楚。

特别是聊天的数据流转逻辑,从HTTP请求、长连接推送以及数据库操作,无所不包。但是图整理完之后,对于主要流程几乎是掌握的比较清楚了,即使回头有忘记的,回头查看表之后就会一清二楚,不仅便于我熟悉逻辑,对以后的维护也很有益处。

维护

遇到的困境

虽然代码质量较差,但是代码中的bug还算比较少,所以在解决线上bug这个问题上,没有遇到过多的麻烦。但是其中也有几个bug十分麻烦,找了好久才找到问题的原因。

其中有一个,找原因大概找了一周多的时间,最后定位问题在我们的账号系统上。因为历史原因,我们的IM业务和其他业务是两套账号体系,中间经历过一次账号变更,但是由于某些奇葩的原因(有部分业务经历了hack),导致部分用户的账号没有成功的过渡账号变更,导致了一些问题。

表象原因是代码逻辑混乱,bug原因复杂,无法复现并难以定位问题。 但更深的原因是时间久远,我们对当时的代码设计以及业务逻辑变更没有记录,且逻辑上存在缺陷,导致无法快速定位到问题的原因。

思考

对于一个逻辑复杂的业务,首先要考虑的是易拓展、易维护和模块组件间的高度解耦。

对于一个成熟的业务来说,我认为易于维护性更为重要,因为业务已经进入成熟阶段的话就意味着大块功能增加的几率比较低,那么对于线上bug修复、小修小补的功能上的优化就是主要工作。

当前的这个IM业务就是这样的,大家看梳理的代码的第一步应该能看出来,我耗费了大量的时间去做各种备注,然而,这些重要节点的备注应该在开发阶段就应该写好了。包括对于之前两套账号系统存在的原因,包括变更一次账号的原因,是否需要有一个详细的记录?

那么对于这种这种有助于后期维护的记录,我认为我们需要对重要的业务变更以及业务设计要有一个详细的记录,无论是自己作为记录还是为后面接手的同学做个参考,我认为都是极为重要的。

重构

思索需求

经过最开始的了解和一段时间维护,我发现遇到的最大麻烦是数据逻辑、业务逻辑、和UI操作逻辑混到了一起,简直可以说是牵一发而动全身。特别是数据逻辑十分混乱,因为数据逻辑的混乱,导致我对于后面业务逻辑的变更十分费力,测试成本也是指数级上涨,另外UI逻辑杂乱,适配iPhone X的时候也遇到了一些小麻烦。

现在的架构设计的原因是什么?这样设计业务需求是否合理?是否有优化的空间?

分析现状与判断未来

无论是客户端还是服务端,亦或是两端数据交互上,IM业务的架构设计本身就存在很多问题。因为时间短暂,不太可能一次性解决所有的问题,特别是对于服务端来说,一次性的大规模重构可能性极低。

对于现在来说:

  • UI重构是首当其冲的, 在保证现有逻辑的基础上,重新设计UI层的逻辑结构,保证代码的复用性和可扩展性,为了将来有可能的业务升级留足空间。
  • 梳理基础组件,比如HTTP、长连接协议和数据库,还有其他的一些工具类,通过封装成组件和组件引用来是他们能从业务逻辑中独立出来。便于独立维护、升级甚至完整替换
  • 将原有的数据操作逻辑从UI逻辑中完整抽离出来,需要达到向下对基础组件要有封装和控制,向上对业务逻辑要有承接,而且依然要做到耦合度尽量的低。
  • 因为IM业务在多个App中都存在,功能逻辑上也有非常多的代码重合,所有需要考虑多个项目通用兼容的问题。

架构设计

  • 工厂模式
  • 瘦Model
  • 去model化的Cell
  • 项目优先,分离核心业务模块组成pod
  • 考虑业务变更可能性,尽可能向上保持API稳定性
  • KVO进行反向传值

着手动工

UI层重构

UI层的重构是最先开始的,无论架构怎么变,UI层都是直接面向用户的,直接承载了产品的业务功能实现,所以为了灵活适应业务的升级或变化,给用户一个好用流畅的入口,UI层设计上要尽可能的灵活。耦合尽可能的小,流畅性上要有保证。

重构思路相对简单,重写View和Controller,去除冗余复杂的UI逻辑代码,规范并统一第三方框架使用,封装公用组件,隔离胶水代码,设计灵活的UI结构。

因为IM系统的最主要UI仍然是TableView,所以针对TableView的各种优化就是重中之重,我着重说一下我对于复杂Cell类型的设计方案。

1、共有的组件有很多,比如时间、头像、背景气泡等等,所以说子类继承父类是最基础的方案。
2、弃置Autolayout的UI书写方式,完全用Frame来写UI布局。Cell高度以及内部UI组件的布局和位置,通过异步计算并缓存为LayoutModel,通过这种方式降低计算的重复耗时操作。
3、有的消息类型只是负责展示,但是有的确有相对复杂的业务逻辑,但是为了防止Cell代码的膨胀,采用了瘦Cell的方式分离逻辑,力求使Cell尽量只负责UI的承载和展示,增加helper层处理相关逻辑。
4、因为虽然是相同的一个数据,但是呈现的方式会存才差异化,所以采用瘦Model的形式,通过创建Helper对取到的原始数据进行相对应的加工,直接提供给业务逻辑处理好的数据。在AFNetworking给的Demo中,是一个典型的胖Model的例子,倒不是说他的例子不好,只是随着业务逻辑的复杂以及生数据和熟数据的差异越来越大的时候,胖Mode的代码量会几何级数般的膨胀,所以还是要因地制宜,具体情况具体分析。
5、利用Factory模式分离出关于复杂Cell类型的判断,包括初始化、赋值等。
6、使用KVO取代delegate进行反向传值,用以减少代码耦合。

关于如何保证UI性能以及优化ViewController,可参考我的其他几篇Blog,iOS 性能优化的探索复杂业务下UIViewController的减负工作

最终结果,第一个完整case的UI层Controller代码,从3000行直接缩减到了1200行,Controller中没有复杂的多方数据处理逻辑,复杂的逻辑判断。只作为UI展示以及接口调用,完全剥离了数据逻辑的处理,所有的处理逻辑由下面的数据逻辑层处理。

数据逻辑层重构

我在分析了业务需求并设计了架构之后,决定重构以自下而上的顺序来进行,于是第一部分就是对于数据逻辑层的重构。步骤如下: 此部分,分为以下三层结构:

1、业务数据逻辑层
2、适配器层(adapter)
3、基础组件服务层(server)

1、业务数据逻辑层

这部分的主要作用是直接受到UI层的调用,负责长连接以及短连接的建立,数据库的初始化操作等。 向上直接承接UI逻辑和业务逻辑,是高度面向业务封装的接口。比如在发送照片消息的时候,只需要将调用API传入Image对象,其他的流程比如说是上传资源以及组成message对象等,则不需要上一层调用和考虑。

所以这一层尽可能的会很薄,不会有特别多的逻辑代码。

2、适配器层(adapter)

这部分的主要工作是承上启下,承接上一层的面向业务的封装,调用下一层基础组件服务层的接口,可以说绝大多数的接口封装都集中在这一层。因为我们有些业务的长连接和短连接的使用上不是很合理,所以我将长短连接都封装到了一个网络服务的类中,此后假如长短连接的业务产生了变化,但仍可以保持向上的接口稳定性。

举个例子说,当推送来一条消息之后,是通过长连接,但是需要收到数据之后在进行AFN的操作完成消息体完整数据的获取,之后要存入数据库并且将是否读取状态设置为NO,当用户读取当前消息之后,将这部分消息还要更新为已读状态。

这部分操作涉及到了所有的组件的操作,但是反馈到最上面一层的时候,大概只是新的消息,并且是完整的消息,然后再刷新UI。所以说,这一层的业务量比较大,几乎是要按照各种标准操作,完整的处理好所有组件的接口。

3、基础组件服务层(server)

这部分基本可以说是基于IM本身的业务特点,对于基础组件的调用封装。 包括:

1、数据库部分,对于IM消息的数据结构,封装的对于数据库的创建,以及增删改查等接口。以及基于业务的一些接口,例如一次性设置当前聊天的所有消息为已读状态等接口。
2、AFN部分,这部分相对来说就很简单了,基本上是依赖于AFN封装的接口,比如获取当前User的详细信息等。
3、长连接部分,包括对于长连接协议的创建连接、断连以及心跳超时上报等操作,也包括了发送消息和收到消息回调等底层操作。
复制代码

拆分之后的Manager层代码量所见到原来的40%左右,于是改名为Session层。

基础组件层的重构以及封装

这部分因为属于公用的基础组件,所以相对来说只是基础组件的比如说AFN以及数据库(FMDB)是整个App的组成,所以没什么其他的操作,只是单纯做了一层逻辑上分层。

但是对于长连接我们做了一些定制,比如:

1、增加了重连的逻辑机制。
2、增加创建连接以及断掉连接时候各种状态的判断等。
复制代码

主要任务还是集中在对于协议库本身的逻辑补充和健壮性优化等。

其他操作

1、创建枚举文件,扩展标准化的枚举变量。
2、合并以及分割Model,随着业务的扩展,原来的Model设计已经不符合当下的业务发展,根据现在固定的业务,重新设计了Model的集成关系,对于分化严重的也做了重新分割。
3、分离并封装了胶水代码到一个大的工具类,便于调用和调试。

走过的弯路

1、过度思考代码解耦合而忽略了业务逻辑复杂性,错将组件化各组件的解耦合的逻辑应用在了本来就是高耦合的MVC架构上。尝试使用去model化的Cell,但是实际操作环节发现增加了大量的逻辑判断,无形中将Model本该处理的业务逻辑转接到Controller和View上,表面上看上去API简洁到家,但是上手代码量并不算小,不利于维护。

2、在一开始采用了MVCS的设计重构UI层,简化Controller中对于Model的处理,在Store中进行了主动和被动网络逻辑、本地数据库调用等。但事实上最后通过封装统一入口的方式将数据处理的逻辑全部从UI逻辑层剥离开,下沉到了数据逻辑层,对于UI层来说只需要考虑的是进行了调用获取数据的API操作或者是被动受到了新的数据,不需要考虑数据来自于服务端、Cache还是本地数据库,也不需要考虑后面的逻辑,当然,从另一个角度说仍然是MVCS,只不过Store相对复杂且庞大。

3、对于UI层和下一层的数据沟通,虽然采用了KVO的方式回调,降低了耦合性,但是仍然存在参数复杂的情况下,传递过多的Key的情况,导致解析稍显困难和复杂。

4、Cell的继承,看上去是一个很直观的设计,但是随着重构代码量的增加以及业务变化发现继承过程中会存在很多问题,通过面向协议等方式或许可以解决继承中庞大Api的问题。

总结以及思考

架构设计的时候,一定要预判用户的使用习惯,判断未来的业务导向,尽可能的降低代码侵入性和耦合性。对于性能产生的影响的地方,通过以上几点来设计架构,模块健壮性以及可扩展性是设计之初就要优先考虑到的。

架构设计分层要清晰,API设计要尽可能简洁,避免暴露过多的接口和参数,避免模块之间的紧耦合,UI设计要尽可能灵活。 重构前,需要思考切入点,是从上值下、从下至上,还是模块化抽离。

已不再维护这部分业务,部分逻辑全凭记忆整理,如果有疏漏或错误,还请大家海涵。

Refrence


作者:derek
链接:https://juejin.cn/post/6844904054577954824

收起阅读 »

iOS Operation 自定义的注意点

iOS
问题 碰到一个问题,就是做一个点击后添加动画效果,连续点击则有多个动画效果按顺序执行,通过自定Operation,以队列实现,但是发现每次点击玩上次动画效果还没完全执行完点击之后的动画就出来,不符合需求。 后来查资料得知自定义Operation中有两个属性分...
继续阅读 »
问题


  • 碰到一个问题,就是做一个点击后添加动画效果,连续点击则有多个动画效果按顺序执行,通过自定Operation,以队列实现,但是发现每次点击玩上次动画效果还没完全执行完点击之后的动画就出来,不符合需求。

  • 后来查资料得知自定义Operation中有两个属性分别表示任务是否在执行以及是否执行完毕,如下


@property (readonly, getter=isExecuting) BOOL executing;
@property (readonly, getter=isFinished) BOOL finished;
复制代码


因此在自定义Operation时设置这两个属性,同时在完全当前队列中任务时给予标识表明任务完成,具体代码如下




  • CustomOperation 类


@interface CustomOperation : NSOperation

@end

#import "CustomOperation.h"

@interface CustomOperation ()

@property(nonatomic,readwrite,getter=isExecuting)BOOL executing; // 表示任务是否正在执行
@property(nonatomic,readwrite,getter=isFinished)BOOL finished; // 表示任务是否结束

@end

@implementation CustomOperation

@synthesize executing = _executing;
@synthesize finished = _finished;

- (void)start
{
    @autoreleasepool {
        self.executing = YES;
        if (self.cancelled) {
            [self done];
            return;
        }
        // 执行任务
        __weak typeof(self) weakSelf = self;
        dispatch_time_t delayTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC));
        dispatch_after(delayTime, dispatch_get_main_queue(), ^{
            NSLog(@"动画完毕");
            // 任务执行完毕手动关闭
            [weakSelf done];
        });
    }
}

-(void)done
{
    self.finished = YES;
    self.executing = NO;
}

#pragma mark - setter -- getter
// 监听并设置executing
- (void)setExecuting:(BOOL)executing
{
    [self willChangeValueForKey:@"isExecuting"];
    _executing = executing;
    [self didChangeValueForKey:@"isExecuting"];
}

- (BOOL)isExecuting
{
    return _executing;
}

// 监听并设置finished
- (void)setFinished:(BOOL)finished
{
    if (_finished != finished) {
        [self willChangeValueForKey:@"isFinished"];
        _finished = finished;
        [self didChangeValueForKey:@"isFinished"];
    }
}

- (BOOL)isFinished
{
    return _finished;
}

// 返回YES 标识并发Operation
- (BOOL)isAsynchronous
{
    return YES;
}

@end


  • swift版


class AnimationOperation: Operation {

    var animationView:AnimationView? // 动画view
    var superView:UIView? // 父视图
    var finishCallBack:(()->())? // 完成动画的回调

    override var isExecuting: Bool {
        return operationExecuting
    }

    override var isFinished: Bool {
        return operationFinished
    }

    override var isAsynchronous: Bool {
        return true
    }
// 监听
    private var operationFinished:Bool = false {
        willSet {
            willChangeValue(forKey: "isFinished")
        }
        didSet {
            didChangeValue(forKey: "isFinished")
        }
    }

    private var operationExecuting:Bool = false {
        willSet {
            willChangeValue(forKey: "isExecuting")
        }
        didSet {
            didChangeValue(forKey: "isExecuting")
        }
    }

// 每次点击添加动画队列
    class func addOperationShowAnimationView(animationView:AnimationView,superView:UIView) -> AnimationOperation {

        let operation = AnimationOperation()
        operation.animationView = animationView
        operation.superView = superView
        return operation
    }

    override func start() {
        self.operationExecuting = true
        if isCancelled == true {
            self.done()
            return
        }

        guard let superView = self.superView,
              let subView = self.animationView,
              let callback = self.finishCallBack else {
            print("superView == nil")
            return
        }

        OperationQueue.main.addOperation {[weak self] in
            superView.addSubview(subView)
            subView.finishCallBack = {
                self?.done()
                callback()
            }
            subView.showAnimation()
        }
    }

    

    private func done() {
        self.operationFinished = true
        self.operationExecuting = false
    }

}

作者:取个有意思的昵称
链接:https://juejin.cn/post/7034518171314815007

收起阅读 »

如何系统性治理 iOS 稳定性问题

iOS
字节跳动如何系统性治理 iOS 稳定性问题本文是丰亚东讲师在2021 ArchSummit 全球架构师峰会中「如何系统性治理 iOS 稳定性问题」的分享全文首先做一下自我介绍:我是丰亚东,2016 年 4 月加入字节跳动,先后负责今日头条 App 的工程架构、...
继续阅读 »

字节跳动如何系统性治理 iOS 稳定性问题

本文是丰亚东讲师在2021 ArchSummit 全球架构师峰会中「如何系统性治理 iOS 稳定性问题」的分享全文

首先做一下自我介绍:我是丰亚东,2016 年 4 月加入字节跳动,先后负责今日头条 App 的工程架构、基础库和体验优化等基础技术方向。2017 年 12 月至今专注在 APM 方向,从 0 到 1 参与了字节跳动 APM 中台的建设,服务于字节的全系产品,目前主要负责 iOS 端的性能稳定性监控和优化。 请添加图片描述 本次分享主要分为四大章节,分别是:1.稳定性问题分类;2.稳定性问题治理方法论;3.疑难问题归因;4.总结回顾。其中第三章节「疑难问题归因」是本次分享的重点,大概会占到60%的篇幅。

一、稳定性问题分类

在讲分类之前,我们先了解一下背景:大家都知道对于移动端应用而言,闪退是用户能遇到的最严重的 bug,因为在闪退之后用户无法继续使用产品,那么后续的用户留存以及产品本身的商业价值都无从谈起。 这里有一些数据想和大家分享:有 20% 的用户在使用移动端产品的时候,最无法忍受的问题就是闪退,这个比例仅次于不合时宜的广告;在因为体验问题流失的用户中,有 1/3 的用户会转而使用竞品,由此可见闪退问题是非常糟糕和严重的。 请添加图片描述 字节跳动作为拥有像抖音、头条等超大量级 App 的公司,对稳定性问题是非常重视的。过去几年,我们在这方面投入了非常多的人力和资源,同时也取得了不错的治理成果。过去两年抖音、头条、飞书等 App 的异常崩溃率都有 30% 以上的优化,个别产品的部分指标甚至有 80% 以上的优化。 通过上图中右侧的饼状图可以看出:我们以 iOS 平台为例,根据稳定性问题不同的原因,将已知稳定性问题分成了这五大类,通过占比从高到低排序:第一大类是 OOM ,就是内存占用过大导致的崩溃,这个比例能占到 50% 以上;其次是 Watchdog,也就是卡死,类比于安卓中的 ANR;再次是普通的 Crash;最后是磁盘 IO 异常和 CPU 异常。 看到这里大家心里可能会有一个疑问:字节跳动究竟做了什么,才取得了这样的成果?接下来我会将我们在稳定性治理方面沉淀的方法论分享给大家。

二、稳定性问题治理的方法论

在这里插入图片描述 首先我们认为在稳定性问题治理方面,从监控平台侧视角出发,最重要的就是要有完整的能力覆盖,比如针对上一章节中提到所有类型的稳定性问题,监控平台都应该能及时准确的发现。 另外是从业务研发同学的视角出发:稳定性问题治理这个课题,需要贯穿到软件研发的完整生命周期,包括需求研发、测试、集成、灰度、上线等,在上述每个阶段,研发同学都应该重视稳定性问题的发现和治理。

上图中右侧是我们总结的两条比较重要的治理原则: 第一条是控制新增,治理存量。一般来说新增的稳定性问题可能是一些容易爆发的问题,影响比较严重。存量问题相对来说疑难的问题居多,修复周期较长。 第二条比较容易理解:先急后缓,先易后难。我们应该优先修复那些爆发的问题以及相对容易解决的问题。 在这里插入图片描述 如果我们将软件研发周期聚焦在稳定性问题治理这个方向上,又可以抽象出以下几个环节: 首先第一个环节是问题发现:当用户在线上遇到任何类型的闪退,监控平台都应该能及时发现并上报。同时可以通过报警以及问题的自动分发,将这些问题第一时间通知给开发者,确保这些问题能够被及时的修复。 第二个阶段是归因:当开发者拿到一个稳定性问题之后,要做的第一件事情应该是排查这个问题的原因。根据一些不同的场景,我们又可以把归因分为单点归因、共性归因以及爆发问题归因。 当排查到问题的原因之后,下一步就是把这个问题修复掉,也就是问题的治理。在这里我们有一些问题治理的手段:如果是在线上阶段,我们首先可以做一些问题防护,比如网易几年前一篇文章提到的基于 OC Runtime 的线上 Crash 自动修复的方案大白,基于这种方案我们可以直接在线上做 Crash 防护;另外由于后端服务上线导致的稳定性问题爆发,我们可以通过服务的回滚来做到动态止损。除了这两种手段之外,更多的场景还是需要研发在线下修复 native 代码,再通过发版做彻底的修复。 最后一个阶段也是最近几年比较火的一个话题,就是问题的防劣化。指的是需求从研发到上线之间的阶段,可以通过机架的自动化单元测试/UI自动化测试,以及研发可以通过一些系统工具,比如说 Xcode 和 Instruments,包括一些第三方工具,比如微信开源的 MLeaksFinder 去提前发现和解决各类稳定性问题。

如果我们想把稳定性问题治理做好的话,需要所有研发同学关注上述每一个环节,才能达到最终的目标。 可是这么多环节我们的重点究竟在哪里呢?从字节跳动的问题治理经验来看,我们认为最重要的环节是第二个——线上的问题的归因。因为通过内部的统计数据发现:线上之所以存在长期没有结论,没有办法修复的问题,主要还是因为研发并没有定位到这些问题的根本原因。所以下一章节也是本次分享的重点:疑难问题归因。

三、疑难问题归因

我们根据开发者对这些问题的熟悉程度做了一下排序,分别是:Crash、Watchdog、OOM 和 CPU&Disk I/O。每一类疑难问题我都会分享这类问题的背景和对应的解决方案,并且会结合实战案例演示各种归因工具究竟是如何解决这些疑难问题的。

3.1 第一类疑难问题 —— Crash

在这里插入图片描述 上图中左侧这张饼状图是我们根据 Crash 不同的原因,把它细分成四大类:包括 Mach 异常、 Unix Signal 异常、OC 和 C++ 语言层面上的异常。其中比例最高的还是 Mach 异常,其次是 Signal 异常,OC 和 C++ 的异常相对比较少。 为什么是这个比例呢? 大家可以看到右上角有两个数据。第一个数据是微软发布的一篇文章,称其发布的 70% 以上的安全补丁都是内存相关的错误,对应到 iOS 平台上就是 Mach 异常中的非法地址访问,也就是 EXC_BAD_ACCESS。内部统计数据表明,字节跳动线上 Crash 有 80% 是长期没有结论的,在这部分 Crash 当中,90% 以上都是 Mach 异常或者 Signal 异常。 看到这里,大家肯定心里又有疑问了,为什么有这么多 Crash 解决不了?究竟难在哪里?我们总结了几点这些问题归因的难点:

  • 首先不同于 OC 和 C++ 的异常,可能开发者拿到的崩溃调用栈是一个纯系统调用栈,这类问题显然修复难度是非常大的;
  • 另外可能有一部分Crash是偶发而不是必现的问题,研发同学想在线下复现问题是非常困难的,因为无法复现,也就很难通过 IDE 调试去排查和定位这些问题;
  • 另外对于非法地址访问这类问题,崩溃的调用栈可能并不是第一现场。这里举一个很简单的例子:A业务的内存分配溢出,踩到了B业务的内存,这个时候我们认为 A 业务应该是导致这个问题的主要原因,但是有可能B业务在之后的某一个时机用到了这块内存,发生了崩溃。显然这种问题实际上是 A 业务导致的,最终却崩在了 B 业务的调用栈里,这就会给开发者排查和解决这个问题带来非常大的干扰。

看到这里大家可能心里又有问题:既然这类问题如此难解,是不是就完全没有办法了呢?其实也并不是,下面我会分享字节内部两个解决这类疑难问题非常好用的归因工具。 在这里插入图片描述

3.1.1 Zombie 检测

首先第一个是 Zombie 检测,大家如果用过 Xcode 的 Zombie 监控,应该对这个功能比较熟悉。如果我们在调试之前打开了 Zombie Objects 这个开关,在运行的时候如果遇到了 OC 对象野指针造成的崩溃,Xcode 控制台中会打印出一行日志,它会告诉开发者哪个对象在调用什么消息的时候崩溃了。

这里我们再解释一下 Zombie 的定义,其实非常简单,指的是已经释放的 OC 对象。 Zombie 监控的归因优势是什么呢?首先它可以直接定位到问题发生的类,而不是一些随机的崩溃调用栈;另外它可以提高偶现问题的复现概率,因为大部分偶现问题可能跟多线程的运行环境有关,如果我们能把一个偶现问题变成必现问题的话,那么开发者就可以借助 IDE 和调试器非常方便地排查问题。但是这个方案也有自己的适用范围,因为它的底层原理基于 OC 的 runtime 机制,所以它仅仅适用于 OC 对象野指针导致的内存问题。 在这里插入图片描述 这里再和大家一起回顾一下 Zombie 监控的原理:首先我们会 hook 基类 NSObject 的 dealloc 方法,当任意 OC 对象被释放的时候,hook 之后的那个 dealloc 方法并不会真正的释放这块内存,同时将这个对象的 ISA 指针指向一个特殊的僵尸类,因为这个特殊的僵尸类没有实现任何方法,所以这个僵尸对象在之后接收到任何消息都会 Crash,与此同时我们会将崩溃现场这个僵尸对象的类名以及当时调用的方法名上报到后台分析。 在这里插入图片描述 这里是字节的一个真实案例:这个问题是飞书在某个版本线上 Top 1 的 Crash,当时持续了两个月没有被解决。首先大家可以看到这个崩溃调用栈是一个纯系统调用栈,它的崩溃类型是非法地址访问,发生在视图导航控制器的一次转场动画,可能开发者一开始看到这个崩溃调用栈是毫无思路的。 在这里插入图片描述 那么我们再看 Zombie 功能开启之后的崩溃调用栈:这个时候报错信息会更加丰富,可以直接定位到野指针对象的类型,是 MainTabbarController 对象在调用 retain 方法的时候发生了 Crash。

看到这里大家肯定有疑问了,MainTabbarController 一般而言都是首页的根视图控制器,理论上在整个生命周期内不应该被释放。为什么它变成了一个野指针对象呢?可见这样一个简单的报错信息,有时候还并不足以让开发者定位到问题的根本原因。所以这里我们更进一步,扩展了一个功能:将 Zombie 对象释放时的调用栈信息同时上报上来。 在这里插入图片描述 大家看倒数第二行,实际上是一段飞书的业务代码,是视图导航控制器手势识别的代理方法,这个方法在调用的时候释放了 MainTabbarController。因为通过这个调用栈找到了业务代码的调用点,所以我们只需要对照源码去分析为什么会释放 TabbarController,就可以定位到这个问题的原因。 在这里插入图片描述 上图中右侧是简化之后的源码(因为涉及到代码隐私问题,所以通过一段注释代替)。历史上为了解决手势滑动返回的冲突问题,在飞书视图导航控制器的手势识别代理方法中写了一段 trick 代码,正是这个 trick 方案导致了首页视图导航控制器被意外释放。 排查到这里,我们就找到了问题的根本原因,修复的方案也就非常简单了:只要下掉这个 trick 方案,并且依赖导航控制器的原生实现来决定这个手势是否触发就解决了这个问题。

3.1.2 Coredump

刚才也提到:Zombie 监控方案是有一些局限的,它仅适用于 OC 对象的野指针问题。大家可能又会有疑问: C 和 C++ 代码同样可能会出现野指针问题,在 Mach 异常和 Signal 异常中,除了内存问题之外,还有很多其他类型的异常比如 EXC_BAD_INSTRUCTION和SIGABRT。那么其他的疑难问题我们又该怎么解决呢?这里我们给出了另外一个解决方案 —— Coredump。 在这里插入图片描述 这个先解释一下什么是 Coredump:Coredump 是由 lldb 定义的一种特殊的文件格式,Coredump 文件可以还原 App 在运行到某一时刻的完整运行状态(这里的运行状态主要指的是内存状态)。大家可以简单的理解为:Coredump文件相当于在崩溃的现场打了一个断点,并且获取到当时所有线程的寄存器信息,栈内存以及完整的堆内存。

Coredump 方案它的归因优势是什么呢?首先因为它是 lldb 定义的文件格式,所以它天然支持 lldb 的指令调试,也就是说开发者无需复现问题,就可以实现线上疑难问题的事后调试。另外因为它有崩溃时现场的所有内存信息,这就为开发者提供了海量的问题分析素材。

这个方案的适用范围比较广,可以适用于任意 Mach 异常或者 Signal 异常问题的分析。 在这里插入图片描述 下面也带来一个线上真实案例的分析:当时这个问题出现在字节的所有产品中,而且在很多产品中的量级非常大,排名Top 1 或者 Top 2,这个问题在之前两年的时间内都没有被解决。

大家可以看到这个崩溃调用栈也全是系统库方法,最终崩溃在 libdispatch 库中的一个方法,异常类型是命中系统库断言。 在这里插入图片描述 我们将这次崩溃的 Coredump 文件上报之后,用前面提到的 lldb 调试指令去分析,因为拥有崩溃时的完整内存状态,所以我们可以分析所有线程的寄存器和栈内存等信息。

这里最终我们分析出:崩溃线程的 0 号栈帧(第一行调用栈),它的 x0 寄程器实际上就是 libdispatch 中定义的队列结构体信息。在它起始地址偏移 0x48 字节的地方,也就是这个队列的 label 属性(可以简单理解为队列的名字)。这个队列的名字对我们来说是至关重要的,因为要修复这个问题,首先应该知道究竟是哪个队列出现了问题。通过 memory read 指令我们直接读取这块内存的信息,最终发现它是一个 C 的字符串,名字叫 com.apple.CFFileDescriptor,这个信息非常关键。我们在源码中全局搜索这个关键字,最终发现这个队列是在字节底层的网络库中创建的,这也就能解释为什么字节所有产品都有这个崩溃了。 在这里插入图片描述 最终我们和网络库的同学一起排查,同时结合 libdispatch 的源码,定位到这个问题的原因是 GCD 队列的外部引用计数小于0,存在过度释放的问题,最终命中系统库断言导致崩溃。 在这里插入图片描述 排查到问题之后,解决方案就比较简单了:我们只需要在这个队列创建的时候,使用 dispatch_source_create 的方式去增加队列的外部引用计数,就能解决这个问题。和维护网络库的同学沟通后,确认这个队列在整个 App 的生命周期内不应该被释放。这个问题最终解决的收益是直接让字节所有产品的 Crash 率降低了8%。

3.2 第二类疑难问题 —— Watchdog

我们进入疑难问题中的第二类问题 —— Watchdog 也就是卡死。 在这里插入图片描述 上图中左侧是我在微博上截的两张图,是用户在遇到卡死问题之后的抱怨。可见卡死问题对用户体验的伤害还是比较大的。那么卡死问题它的危害有哪些呢?

首先卡死问题通常发生于用户打开 App 的冷启动阶段,用户可能等待了10 秒什么都没有做,这个 App 就崩溃了,这对用户体验的伤害是非常大的。另外我们线上监控发现,如果没有对卡死问题做任何治理的话,它的量级可能是普通 Crash 的 2-3 倍。另外现在业界普遍监控 OOM 崩溃的做法是排除法,如果没有排除卡死崩溃的话,相应的就会增加 OOM 崩溃误判的概率。

卡死类问题的归因难点有哪些呢?首先基于传统的方案——卡顿监控:认为主线程无响应时间超过3秒~5秒之后就是一次卡死,这种传统的方案非常容易误报,至于为什么误报,我们下一页中会讲到。另外卡死的成因可能非常复杂,它不一定是单一的问题:主线程的死锁、锁等待、主线程 IO 等原因都有可能造成卡死。第三点是死锁问题是一类常见的导致卡死问题的原因。传统方案对于死锁问题的分析门槛是比较高的,因为它强依赖开发者的经验,开发者必须依靠人工的经验去分析主线程到底跟哪个或者哪些线程互相等待造成死锁,以及为什么发生死锁。 在这里插入图片描述 大家可以看到这是基于传统的卡顿方案来监控卡死,容易发生误报。为什么呢?图中绿色和红色的部分是主线程的不同耗时阶段。假如主线程现在卡顿的时间已经超过了卡死阈值,刚好发生在图中的第5个耗时阶段,我们在此时去抓取主线程调用栈,显然它并不是这次耗时的最主要的原因,问题其实主要发生在第4个耗时阶段,但是此时第4个耗时阶段已经过去了,所以会发生一次误报,这可能让开发者错过真正的问题。

针对以上提到的痛点,我们给出了两个解决方案:首先在卡死监控的时候可以多次抓取主线程调用栈,并且记录每次不同时刻主线程的线程状态,关于线程状态包括哪些信息,下一页中会提到。 另外我们可以自动识别出死锁导致的卡死问题,将这类问题标识出来,并且可以帮助开发者自动还原出各个线程之间的锁等待关系。 在这里插入图片描述 首先是第一个归因工具——线程状态,这张图是主线程在不同时刻调用栈的信息,在每个线程名字后面都有三个 tag ,分别指的是三种线程的状态,包括当时的线程 CPU 占用、线程运行状态和线程标志。

上图中右侧是线程的运行状态和线程标志的解释。当看到线程状态的时候,我们主要的分析思路有两种:第一种,如果看到主线程的 CPU 占用为 0,当前处于等待的状态,已经被换出,那我们就有理由怀疑当前这次卡死可能是因为死锁导致的;另外一种,特征有所区别,主线程的 CPU 占用一直很高 ,处于运行的状态,那么就应该怀疑主线程是否存在一些死循环等 CPU 密集型的任务。 在这里插入图片描述 第二个归因工具是死锁线程分析,这个功能比较新颖,所以首先带领大家了解一下它的原理。基于上一页提到的线程状态,我们可以在卡死时获取到所有线程的状态并且筛选出所有处于等待状态的线程,再获取每个线程当前的 PC 地址,也就是正在执行的方法,并通过符号化判断它是否是一个锁等待的方法。

上图中列举了目前我们覆盖到的一些锁等待方法,包括互斥锁、读写锁、自旋锁、 GCD 锁等等。每个锁等待的方法都会定义一个参数,传入当前锁等待的信息。我们可以从寄存器中读取到这些锁等待信息,强转为对应的结构体,每一个结构体中都会定义一个线程id的属性,表示当前这个线程正在等待哪个线程释放锁。对每一个处于等待状态的线程完成这样一系列操作之后,我们就能够完整获得所有线程的锁等待关系,并构建出锁等待关系图。 在这里插入图片描述 通过上述方案,我们可以自动识别出死锁线程。假如我们能判断 0 号线程在等待 3 号线程释放锁, 同时3 号线程在等待0号线程释放锁,那么显然就是两个互相等待最终造成死锁的线程。

大家可以看到这里主线程我们标记为死锁,它的 CPU 占用为 0,状态是等待状态,而且已经被换出了,和我们之前分析线程状态的方法论是吻合的。 在这里插入图片描述 通过这样的分析之后,我们就能够构建出一个完整的锁等待关系图,而且无论是两个线程还是更多线程互相等待造成的死锁问题,都可以自动识别和分析。 在这里插入图片描述 这是上图中死锁问题的一段示意的源码。它的问题就是主线程持有互斥锁,子线程持有 GCD 锁,两个线程之间互相等待造成了死锁。这里给出的解决方案是:如果子线程中可能存在耗时操作,尽量不要和主线程有锁竞争关系;另外如果在串行队列中同步执行 block 的话,一定要慎重。 在这里插入图片描述 上图是通过字节内部线上的监控和归因工具,总结出最常见触发卡死问题的原因,分别是死锁、锁竞争、主线程IO、跨进程通信。

3.3 第三类疑难问题 —— OOM

OOM 就是 Out Of Memory,指的是应用占用的内存过高,最终被系统强杀导致的崩溃。 在这里插入图片描述 OOM 崩溃的危害有哪些呢?首先我们认为用户使用 App 的时间越长,就越容易发生 OOM 崩溃,所以说 OOM 崩溃对重度用户的体验伤害是比较大的;统计数据显示,如果 OOM 问题没有经过系统性的治理,它的量级一般是普通 Crash 的 3-5 倍。最后是内存问题不同于 Crash 和卡死,相对隐蔽,在快速迭代的过程中非常容易劣化。

那么 OOM 问题的归因难点有哪些呢?首先是内存的构成是非常复杂的事情,并没有非常明确的异常调用栈信息。另外我们在线下有一些排查内存问题的工具,比如 Xcode MemoryGraph 和 Instruments Allocations,但是这些线下工具并不适用于线上场景。同样是因为这个原因,如果开发者想在线下模拟和复现线上 OOM 问题是非常困难的。 在这里插入图片描述 这里我们给出解决线上 OOM 疑难问题的归因工具是MemoryGraph。这里的 MemoryGraph 主要指的是在线上环境中可以使用的 MemoryGraph。跟 Xcode MemoryGraph 有一些类似,但是也有不小的区别。最大的区别当然是它能在线上环境中使用,其次它可以对分散的内存节点进行统计和聚合,方便开发者定位头部的内存占用。

这里带领大家再回顾一下线上 MemoryGraph 的基本原理:首先我们会定时的去检测 App 的物理内存占用,当它超过危险阈值的时候,就会触发内存 dump,此时 SDK 会记录每个内存节点符号化之后的信息,以及他们彼此之间的引用关系,如果能判定出是强引用还是弱引用,也会把这个强弱引用关系同时上报上来,最终这些信息整体上报到后台之后,就可以辅助开发者去分析当时的大内存占用和内存泄露等异常问题。

这里我们还是用一个实战案例带领大家看一下 MemoryGraph 到底是如何解决 OOM 问题的。 在这里插入图片描述 分析 MemoryGraph 文件的思路一般是抽丝剥茧,逐步找到根本原因。

上图是 MemoryGraph 文件分析的一个例子,这里的红框标注了不同的区域:左上角是类列表,会把同一类型对象的数量以及它们占用的内存大小做一个汇总;右侧是这个类所有实例的地址列表,右下角区域开发者可以手动回溯对象的引用关系(当前对象被哪些其他对象引用、它引用了哪些其他对象),中间比较宽的区域是引用关系图。

因为不方便播放视频,所以这边就跟大家分享一些比较关键的结论:首先看到类列表,我们不难发现 ImageIO 类型的对象有 47 个,但是这 47 个对象居然占了 500 多 MB 内存,显然这并不是一个合理的内存占用。我们点开 ImageIO 的类列表,以第一个对象为例,回溯它的引用关系。当时我们发现这个对象只有一个引用,就是 VM Stack: Rust Client Callback ,它实际上是飞书底层的 Rust 网络库线程。 排查到这里,大家肯定会好奇:这 47 个对象是不是都存在相同的引用关系呢?这里我们就可以用到右下角路径回溯当中的 add tag 功能,自动筛选这 47 个对象是否都存在相同的引用关系。大家可以看到上图中右上角区域,通过筛选之后,我们确认这 47 个对象 100% 都有相同的引用关系。

我们再去分析 VM Stack: Rust Client Callback这个对象。发现它引用的对象中有两个名字非常敏感,一个是 ImageRequest,另外一个是 ImageDecoder ,从这两个名字我们可以很容易地推断出:应该是图片请求和图片解码的对象。 在这里插入图片描述 我们再用这两个关键字到类列表中搜索,可以发现 ImageRequest 对象有 48 个,ImageDecoder 对象有 47 个。如果大家还有印象的话,上一页中占用内存最大的对象 ImageIO 也是 47 个。这显然并不是一个巧合,我们再去排查这两类对象的引用关系,发现这两类对象也同样是 100% 被 VM Stack: Rust Client Callback 对象所引用。

最终我们和飞书图片库的同学一起定位到这个问题的原因:在同一时刻并发请求 47 张图片并解码,这不是一个合理的设计。问题的根本原因是飞书图片库的下载器依赖了 NSOperationQueue 做任务管理和调度,但是却没有配置最大并发数,在极端场景下就有可能造成内存占用过高的问题。与之相对应的解决方案就是对图片下载器设置最大并发数,并且根据待加载图片是否在可视区域内调整优先级。 在这里插入图片描述 上图是通过字节内部的线上监控和归因工具,总结出来最常见的几类触发 OOM 问题的原因,分别是:内存泄露,这个较为常见;第二个是内存堆积,主要指的是 AutoreleasePool 没有及时清理;第三是资源异常,比如加载一张超大图或者一个超大的 PDF 文件;最后一个是内存使用不当,比如内存缓存没有设计淘汰清理的机制。

3.4 第四类疑难问题 —— CPU 异常和磁盘 I/O 异常

这里之所以把这两类问题合并在一起,是因为这两类问题是高度相似的:首先它们都属于资源的异常占用;另外它们也都不同于闪退,导致崩溃的原因并不是发生在一瞬间,而都是持续一段时间的资源异常占用。 在这里插入图片描述 异常 CPU 占用和磁盘 I/O 占用危害有哪些呢?首先我们认为,这两类问题即使最终没有导致 App 崩溃,也特别容易引发卡顿或者设备发烫等性能问题。其次这两类问题的量级也是不可以被忽视的。另外相比之前几类稳定性问题而言,开发者对这类问题比较陌生,重视程度不够,非常容易劣化。

这类问题的归因难点有哪些呢?首先是刚刚提到它的持续时间非常长,所以原因也可能并不是单一的;同样因为用户的使用环境和操作路径都比较复杂,开发者也很难在线下复现这类问题;另外如果 App 想在用户态去监控和归因这类问题的话,可能需要在一段时间内高频的采样调用栈信息,然而这种监控手段显然性能损耗是非常高的。 在这里插入图片描述 上图中左侧是我们从 iOS 设备中导出的一段 CPU 异常占用的崩溃日志,截取了关键部分。这部分信息的意思是:当前 App 在 3 分钟之内的 CPU 时间占用已经超过80%,也就是超过了 144 秒,最终触发了这次崩溃。

上图中右侧是我截取苹果 WWDC2020 一个 session 中的截图,苹果官方对于这类问题,给出了一些归因方案的建议:首先是 Xcode Organizer,它是苹果官方提供的问题监控后台。然后是建议开发者也可以接入 MetricKit ,新版本有关于 CPU 异常的诊断信息。 请添加图片描述 上图中左侧是磁盘异常写入的崩溃日志,也是从 iOS 设备中导出,依然只截取了关键部分:在 24 小时之内,App 的磁盘写入量已经超过了 1073 MB,最终触发了这次崩溃。

上图中右侧是苹果官方的文档,也给出了对于这类问题的归因建议。同样是两个建议:一个是依赖 Xcode Organizer,另一个是依赖 MetricKit。我们选型的时候最终确定采用 MetricKit 方案,主要考虑还是想把数据源掌握在自己手中。因为 Xcode Organizer 毕竟是一个苹果的黑盒后台,我们无法与集团内部的后台打通,更不方便建设报警、问题自动分配、issue状态管理等后续流程。 请添加图片描述 MetricKit 是苹果提供的官方性能分析以及稳定性问题诊断的框架,因为是系统库,所以它的性能损耗很小。在 iOS 14 系统以上,基于Metrickit,我们可以很方便地获取 CPU 和磁盘 I/O 异常的诊断信息。它的集成也非常方便。我们只需要导入系统库的头文件,设置一个监听者,在对应的回调中把 CPU 和磁盘写入异常的诊断信息上报到后台分析就好了。 请添加图片描述 其实这两类异常的诊断信息格式也是高度类似的,都是记录一段时间内所有方法的调用以及每个方法的耗时。上报到后台之后,我们可以把这些数据可视化为非常直观的火焰图。通过这样直观的形式,可以辅助开发者轻松地定位到问题。对于上图中右侧的火焰图,我们可以简单的理解为:矩形块越长,占用的 CPU 时间就越长。那么我们只需要找到矩形块最长的 App 调用栈,就能定位到问题。图中高亮的红框,其中有一个方法的关键字是 animateForNext,看这个名字大概能猜到这是动画在做调度。

最终我们和飞书的同学一起定位到这个问题的原因:飞书的小程序业务有一个动画在隐藏的时候并没有暂停播放,造成了 CPU 占用持续比较高。解决方案也非常简单,只要在动画隐藏的时候把它暂停掉就可以了。

四、总结回顾

请添加图片描述 在第二章节稳定性问题治理方法论中,我提到“如果想把稳定性问题治理好,就需要将这件事情贯穿到软件研发周期中的每一个环节,包括问题的发现、归因、治理以及防劣化”,同时我们认为线上问题特别是线上疑难问题的归因,是整个链路中的重中之重。针对每一类疑难问题,本次分享均给出了一些好用的归因工具:Crash 有 Zombie 监控和 Coredump;Watchdog 有线程状态和死锁线程分析;OOM 有 MemoryGraph;CPU 和磁盘 I/O 异常有 MetricKit。 请添加图片描述 本次分享提到的所有疑难问题的归因方案,除了MetricKit 之外,其余均为字节跳动自行研发,开源社区尚未有完整解决方案。这些工具和平台后续都将通过字节火山引擎应用开发套件 MARS 旗下的 APM Plus 平台提供一站式的企业解决方案。本次分享提到的所有能力均已在字节内部各大产品中验证和打磨多年,其自身的稳定性以及接入后所带来的业务效果都是有目共睹的,欢迎大家持续保持关注。

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

收起阅读 »

什么是元宇宙?Facebook 的战略以及微软、迪士尼和亚马逊如何取胜。

什么是元宇宙?简单地说,元界是一个数字空间,由人、地点和事物的数字表示形式表示。换句话说,这是一个“数字世界”,由数字对象代表真实的人。在很多方面,Microsoft Teams 或 Zoom 已经是 Metaverse 的一种形式。你在房间里“在那里”,但你...
继续阅读 »

什么是元宇宙?

简单地说,元界是一个数字空间,由人、地点和事物的数字表示形式表示。换句话说,这是一个“数字世界”,由数字对象代表真实的人。

在很多方面,Microsoft Teams 或 Zoom 已经是 Metaverse 的一种形式。你在房间里“在那里”,但你可能是一个静态图像、一个化身或一个直播视频。所以元界是一个更广泛的“将人们聚集在一起”的背景。

它可用于许多事情:会议、参观工厂车间、入职或培训。事实上,几乎所有与人力资源和人才相关的项目都可以为元界重新设计。如果您戴上 3D 眼镜,Metaverse 将完全身临其境。


为什么感觉这么奇怪?
许多供应商将在这个领域发挥作用。
当然,Facebook 选择重命名整个公司是为了说明这一点。微软(我将在下面解释)已经有一个主要的存在。但它会更大。事实上,每家科技公司、零售商和娱乐企业都想加入。

当你向专家询问元界这个词时,他们解释说它是多年前在一本科幻小说中创造的。但对于我们其他人来说,这听起来像是马克·扎克伯格想出的一些令人毛骨悚然的事情来捕捉我们所有的信息并向我们出售更多的广告。(并且可能会制造出更可怕的阴谋论、假新闻等等。)

不幸的是,由于 Facebook 似乎首先提出了这一点,因此有很多关于其价值的阴谋论。

好吧,让我向你保证这件事是真实的。对我们生活的价值可能很大。

首先让我建议没有一个“Metaverse”。将会有很多“元界”,一些用于商业,一些用于商业,一些用于教育,一些用于娱乐。首先让我主要谈谈业务应用程序,但我相信其他的很快就会出现。

商业元界可以做什么

首先让我提一下,今天的 Metaverse 是 AOL 期间互联网的所在地。换句话说,有很多新的东西要来。请记住,最初的网络都是基于文本的,速度很慢,甚至没有任何视频。

商业元界已经成型。在培训中,我们希望从“电子学习”到“我们学习”再到“数字学习”,再到“沉浸式学习”。这意味着大量的新应用程序——从入职和培训到领导力发展、会议、模拟体验、大型员工活动,当然还有娱乐。

大公司已经尝试了一段时间。埃森哲基于微软早期发布的 Mesh(其 Metaverse 和 Avatar 工具集)为顾问构建了一个完整的“Nth Floor”

我刚刚与微软的产品团队进行了交谈,他们的计划令人印象深刻Microsoft Mesh for Teams 将于明年年中推出,让您可以用头像替换您的视频状态,创建虚拟房间,并在 Teams 中实现 3D 空间。想象一下贸易展、学习会议或 3D 入职体验,所有这些都基于 Teams。我不得不相信,人们会对这项技术产生兴趣。

至于成为化身的价值?它实际上比你想象的更有价值。游戏玩家已经了解到,使用头像的人可以更有表现力、更诚实,并且在心理上更有安全感。快速发展的培训公司 Mursion了解到,基于虚拟形象的学习让人们在体验中更加诚实和真实,使他们能够比面对面的培训更好地学习和改变行为。(你必须经历它才能相信我。)

如果你只是害羞、内向、疲倦,或者可能是残疾人怎么办?化身是一种让您以一种新的有趣的方式“做自己”的方式,开启了一系列在传统环境中可能不舒服或不可能的对话和互动。

我还想象 Microsoft Metaverse(和其他供应商会这样做)将让您参观和了解制造您的产品的工厂。您的销售团队可以与您的客户进行真实的模拟。如果您使用像 STRIVR 这样专业开发平台,您将能够体验公司中每个运营、高风险或高价值培训场景的 3D 模拟。在 STRIVR 中的体验已经远低于现实世界的模拟,而且应用程序令人惊叹。

例如,想一想 Verizon 如何教其门店员工如何应对第一人称射击游戏?沃尔玛如何培训员工为黑色星期五做好准备。联邦快递如何教其包装工在卡车后部完美地包装和利用包裹?或者 JetBlue 如何教其飞行机械师在飞机下方行走时检查安全性。


埃森哲已经跃入这个大局。
除了试用 Microsoft Mesh 外,该公司还成立了一个完整的 AR 咨询团队,
帮助大公司构建基于元界的解决方案我们可以期待 Zoom、Webex(思科)、Nvidia、Netflix 和 Apple 等其他供应商加入。我见过的最有趣的 Metaverse 应用程序之一(来自 STRIVR)是一家公用事业公司,它教其运营人员如何爬下检修孔,识别需要调整的仪器和安全阀,并在不造成危险的情况下修复或诊断问题. 
如果没有 VR,这种类型的培训将是昂贵的、容易发生风险的并且是高度配给的。使用 STRIVR(Metaverse 应用程序)可以大规模体验。

为什么微软能大获全胜

与所有新技术一样,将会有很多“快速致富”的想法出现。虽然 Facebook 可能会经常谈论它,但他们不能“拥有”Metaverse。这就像说 Facebook“拥有互联网”一样。Metaverse 是许多提供商将构建的一组新技术,现在是一场竞赛,看谁能最快增加价值。

就我个人而言,我现在是微软战略的粉丝。该公司专注于构建 Mesh,这是一个支持 Teams 和其他应用程序的平台,以及Hololens,一种广泛用于制造、教育和军事的增强现实解决方案。微软希望为商业、教育、培训和娱乐启用 Metaverse 应用程序。这些都是现实世界的需求,每个需求都可以通过 Avatars、VR 和 AR 来增强和重塑。


微软的 AR 耳机和平台 Hololens 已经同样强大。
宝马、戴姆勒和福特等公司正在
为制造专业人士使用该技术,它可以帮助您快速学习、避免错误以及跟踪和改进流程质量。

我在听Jarod Lanier,VR 的先驱之一,描述 Facebook 的 Meta 公告他的分析很有趣:他对扎克伯格的评论:Facebook的战略是什么?

听完马克·扎克伯格的演讲后,听起来就像是某个狂妄自大的人拿走了我的东西,并通过某种奇怪的自我夸大过滤器进行了过滤。我的意思是,这只是最奇怪的事情。我一直认为你会出现,就像 1 亿微型企业家在这里和那里做他们的小事。而且不会有什么霸主……

他和我一样认为,Facebook 对苹果成为其广告业务的守门人感到不安。因此,通过专注于 Metaverse,公司可以尝试使 Oculus 成为未来的“平台”。然后 Meta 成为该域的下一个看门人。这是 Jarod 的看法:

我的意思是,我要大胆地猜测,如果蒂姆库克没有开始关闭 Facebook 对免费数据的访问,那 Meta 就永远不会发生。我认为 Meta 的意义在于,我们不拥有本轮获取数据的外围设备。所以在未来,我们需要赢得下一场设备大战,这样我们才能获得数据。而且,哦,如果我们拥有智能扬声器和门把手或其他任何东西,那就太好了,但亚马逊拥有这些。所以让我们去买耳机吧。我认为这就是它的最终目的。这是关于您必须拥有边缘设备才能拥有自己的力量来使您的云成为好或坏的事实。而且他显然想让它变得邪恶,所以他需要拥有一个设备,而他目前没有。

鉴于 Facebook 的商业模式,Meta 很可能会专注于广告。因此,您的 Facebook 体验可能涉及将您的大量个人数据提供给广告商(Facebook 的真实客户)。事实上,2018 年Oculus 执行官罗伯特·鲁宾曾表示“这是我们要失去的市场”。

收入也将来自广告,这是 Facebook 最了解的市场。鲁宾想象 可口可乐 为展馆的主要位置 付费, 福特为其虚拟汽车付费以使其可用,或者 宝洁公司 在数字广告牌上宣传其品牌。Gucci 可以开设一家虚拟商店, 康卡斯特 (CNBC 母公司 NBCUniversal 的所有者)将为“一个巨大的标语支付费用,上面写着‘康卡斯特:获得更好的 MetaSpeed!’”

是的,Facebook 也对企业市场产生了兴趣。Workplace by Facebook 拥有数百名客户,它似乎是一个精心设计的公司社交网络(尽管 Facebook 系统崩溃时它确实崩溃了)。Oculus Horizon Workrooms(可能会更名)正在尝试做 Microsoft Mesh 对 Teams 所做的事情。


迪士尼、Netflix、亚马逊和其他公司呢?
但 Facebook 也面临着挑战。
首先,他们不是一家值得信赖的公司(
只有 15% 的美国人信任 Facebook)。其次,他们的平台远远落后于微软。是的,他们很顽强,但在企业界,他们还有很长的路要走。

如果我猜想的会出现,我们将看到其他“元宇宙”出现。考虑娱乐业。本周迪士尼宣布将采取行动,将“连接数字世界和物理世界”用于讲故事和动画。谁不想走进 3D 或虚拟化身驱动的迪士尼电影并与角色交谈?迪士尼可能会成为“元界中最快乐的地方”。


这将随着时间的推移而发生
购物元界怎么样?
您不想去虚拟的亚马逊(或沃尔玛)商店购买产品、杂货、书籍和电影吗?消费者可以与作者交谈,拜访电影角色,并以 3D 形式观看食品。
亚马逊拥有抽搐,在世界上最大的游戏网络。认为他们没有在这方面工作?继续猜。得到我的漂移?

还有很多“边缘情况”尚未发生。NFT(Non-Fungible Tokens)将成为这个世界的一部分——使我们能够购买、拥有、许可和保护数字资产。由于此处捕获的 3D 数据比以往任何时候都多,因此将大量关注隐私、安全和数据保护。随着眼镜和耳机变得更便宜(在这个领域看 Apple),你可以打赌这些应用程序将影响我们的工作、家庭和周末生活。

并且还会有土地掠夺。品牌已经在收购有价值的房地产(视频游戏中的“虚拟空间”),因此您的公司可能想在 Farmville 购买一块土地。耐克只是为数字产品申请了专利,所以他们将来会销售数字产品。

在您认为这是一场反乌托邦的噩梦之前,请考虑一下这种转变的积极意义。我们多年来一直在构建的许多技术(区块链、VR、AR、传感器、相机、5G)现在正在融合在一起。我们今天所知的 Metaverse 一开始可能看起来很奇怪或不寻常,但很快你就会看到真正的应用程序出现。

我们将保持警惕,让我们看看会发生什么。

收起阅读 »

(转载)“元宇宙”究竟是什么

摘要:什么是“元宇宙”,1000个人眼里有1000个“元宇宙”。 本文分享自华为云社区《【云驻共创】年轻人如何入场元宇宙?未来已来!》,作者:启明。 近期,Facebook把自己公司更名为Meta(元),上了一波热搜;而前段时间国内的阿里腾讯,国外的谷歌等巨...
继续阅读 »

摘要:什么是“元宇宙”,1000个人眼里有1000个“元宇宙”。



本文分享自华为云社区《【云驻共创】年轻人如何入场元宇宙?未来已来!》,作者:启明。


近期,Facebook把自己公司更名为Meta(元),上了一波热搜;而前段时间国内的阿里腾讯,国外的谷歌等巨头纷纷宣布入局元宇宙;行业“冥”灯罗永浩也宣布要入局元宇宙,这个词汇出现的越来越频繁,而未来会更频繁。


那么“元宇宙”究竟是什么呢?


1000个人眼里有1000个“元宇宙”


什么是“元宇宙”,1000个人眼里有1000个“元宇宙”。


不同的媒体、公司、个人等,对“元宇宙”都有着自己的理解:


有的人认为“元宇宙”代表着人类文明的未来;而还有些人觉得“元宇宙”代表着虚拟世界的“躺平”,是个“邪恶”的东西,Elon Musk的冲向太空,才是人类文明的未来.....


而在这两种观点之间,还有一种观点:“元宇宙”和互联网一样,本身是不带任何属性的,重点还是看我们如何使用它。


要探讨什么是“元宇宙”,我们需要探索人类需求的本源。


影视文学中的元宇宙


首先,我们从影视文学中的元宇宙的角度来挖掘一下:


《雪崩》


对于元宇宙的解释,目前公认翻译自1992年斯蒂芬森科幻小说《雪崩》中“Metaverse”(也译为超元域)一词。元宇宙简单来说,就是现实世界中的所有人和事都被数字化投射在了这个网络云端世界里,你可以在这个世界里做任何你在真实世界中可以做的事情。与此同时,你还可能做你在真实世界里做不到的事情。


《庄周梦蝶》


而在中国2300多年前的百家争鸣时代,庄子梦到了自己变成了蝴蝶在翩翩飞舞,醒来之后不知身在何处,就产生了这样的思考:到底是庄子变成了蝴蝶,还是蝴蝶变成了庄子呢?哪个才是真是的存在?还有《枕中记》一书中的“黄粱一梦”等成语也是类似的哲思探索。


《星球大战》


在《星球大战》中我们看到具备三维全息投影功能的R2D2机器人;以及目前在游戏玩家中很流行的玩意儿:由一个全视角显示头盔和一套感应服构成,感应服可以使玩家从肉体上感觉到游戏中的击打、刀刺和火烧,能产生出酷热和严寒,甚至还能逼真地模拟出身体暴露在风雪中的感觉。在《三体》改编的同名游戏中提到,汪淼走到她后面,由于游戏是在头盔中以全视角方式显示的,在显示器上什么都看不到。


《王牌特工》


在美国影视剧《王牌特工》中,当你带上王牌特工的专属AR眼镜,其他与会人哪怕身在不同国家地区,都能就在身边一样,开一个全息会议。


《阿凡达》


在《阿凡达》影视剧中,科学家尝试将人类DNA和纳威人的DNA结合在一起,制造出一个克隆纳威人。而最神奇的地方在于克隆纳威人可以让人类的意识入驻其中,从而成为人类在这个星球上活动的“化身”(Avatar)。


《黑镜》


在《黑镜》第二季第一集当中,名为“Be Right Back·马上回来”,讲述了一对情侣Martha和Ash搬去了Ash父母居住的远离尘嚣的小镇生活,但是社交网络狂人Ash却在归还搬家租赁的货车时死于非命。在Ash的葬礼上,Martha的朋友Sarah告诉了她一种和死去的人建立联系的新方法,就是用Ash在社交网络中留下的所有信息、状态,更新和Like,Martha可以创造出一个新的“真”Ash,从而帮助她减轻伤痛。


《刀剑神域》


在《刀剑神域》中,当你带上脑机接口设备时,你在游戏中死亡,那么在现实中也将死亡。


《太空堡垒》


《太空堡垒》有3个非常了不起的设定:


1、全息眼镜Holoband和虚拟paradise


2、意识上传到云端


3、意识下载到机器人的身体中,成为第一代具备真正“智慧”的机器人


《头号玩家》


2045年,处于混乱和崩溃边缘的现实世界令人失望,人们将救赎的希望寄托于“绿洲”,一个由鬼才詹姆斯·哈利迪一手打造的虚拟游戏世界。人们只要戴上VR设备,就可以进入这个与现实世界形成强烈反差的虚拟世界。在这个世界中,有繁华的都市,形象各异、光彩照人的玩家,而不同次元的影视游戏中的经典角色也可以在这里齐聚。就算你在现实中是一个挣扎在社会边缘的失败者,在“绿洲”里也依然可以成为超级英雄,再遥远的梦想都变得触手可及。


《失控玩家》


今年上映的一个新的影视剧。其中的主角以为自己是生活的主角,其实只不是这个世界的一个NPC,这个舞台上的提线木偶。


《UPLOAD》


《UPLOAD》中的精彩设定:


1、死前将意识上传到虚拟天堂


2、死后的世界也有2G和5G之分


3、死后通过全息方式参加自己的葬礼


《黑客帝国》


未来的人类生活在机器人所制造的矩阵(Matrix)虚拟世界中,而机器人则得以从人体获取所需的生物能源。但生活在虚拟世界中的人类丝毫没有意识到自己的世界是虚拟的,知道“救世主”的出现。


我们并不是来讲解这个科幻影视作品的,我们要做的,是从中这些科幻影视作品中,看看人们的需求。


一提到需求,我们可能会立刻想到马斯克的“需求层次论”,但是在这里,我们更加抽象一下:



**物质需求:**创造价值与财富,提高生产力


**精神需求:**消费与享受生活,社交娱乐


**永生:**彻底脱离生老病死,实现数字化永生


当然,按照目前科技的发展进程,如果想实现人类生物身体的永生不老是非常难的,但是通过Metaverse的技术未来我们有可能会实现一种数字化的永生。


巨头眼中的元宇宙


介绍完影视剧中的“元宇宙”,我们来看看巨头眼中的元宇宙:


Facebook?Meta!


首先我们来看的是Meta,也就是之前的Facebook。对Meta来说,今年是一个非常重要的一年,因为今年它改元。当然我们都知道在中国的历史上,某个帝王更改自己的年号叫“改元”,其实对Facebook对Meta来说,2021年也是它的改元之年。我们可以看一看在它改元之前,它的虚拟现实以及在元宇宙做了哪些布局。


2014年的时候,其用20亿美元将股份收购了OculusVR。在收购的时候,扎克伯格在自己的Facebook主页上说了这样一句话:沉浸式的虚拟现实游戏,将是虚拟现实第一个重大应用,但是这仅仅只是一个起点,虚拟现实绝不仅仅是游戏,我们希望把它打造成下一个计算和通讯平台。


2018年9月,Facebook在OculusConnect开发者大会上宣布推出独立虚拟现实(VR)头盔Oculus Quest,跟Oculus Go类似,这种头盔无需PC或手机即可提供虚拟现实功能。但是它提供了6自由度的游戏控制器,可以让玩家更愉快的玩耍。


2020年9月,Facebook在开发者大会上宣布推出OculusQuest 2代,定价仅299美元。2021年11月17日,根据高通CEO透露的数据,Oculus Quest2代的累积销量已经突破1000万台!!!


那么1000万台意味着什么?意味着 VR头显的设备已经跨越了所谓的第一个极限点,即将迈向真正的星辰大海。


往后接下来几年还会陆续还有Oculus的3代和4代,而这些都在开发之中,而且价格肯定不会比二代贵,也会解决诸多的技术问题,但是具体的发布时间(可能在2022年圣诞节前)还在猜测当中。除了3代和4代之外,还有传闻中的PRO版本,也就是性能更强,价格更高,那么它可能会对标传说中要发布的苹果的新品。当然Meta也就是之前的Facebook,除了现有的Oculus这条产品之外,还在积极的研发AR眼镜。


看完上述的这些产品之外,我们也来看一看Meta还有其他哪些布局:2018年的Facebook F8大会上,Oculus首席科学家Michael Abrash宣布Oculus研发部门Oculus Research重新命名为Facebook Reality Labs,并同时涉足VR与AR技术的研发。


Facebook AR/VR部门在2021年总人数已逾1万人,占总员工人数的近20%,而2017年该部门仅为1000人。


Facebook有两个开发者的大会是值得我们关注的,分别是每年5月到6月的F8大会,以及每年10月底左右的XR开发者大会。


Facebook于2017年发布了名为AR Studio的AR套件,并一直与全球社区合作,共同塑造和定义Spark AR平台。


2021年8月20日,Facebook推出测试性的VR远程办公APP,名为HorizonWorkrooms,有了该软件,Oculus Quest 2用户可以用虚拟化身参与会议。


2021年11月11日,Meta宣布与微软合作,将Meta旗下的WorkPlace功能与微软的Teams整合,发展元宇宙办公室。


我们可以看到,现在虚拟现实和原有的产品和技术的布局,从之前的以数年为单位,现在已经大大的提速,增加到了一年半年甚至几个月都会有一个新的产品新的功能出来。


那么在内容生态上,Meta一是推出Oculus Store,目前已有超过60款Oculus Quest 游戏的营收超过100万美元;二是和第三方平台SideQuest合作。



其还成立了Oculus Studio,并且收购多家VR内容公司,包括Beat Games、Downpour、Ready at Dawn、Sanzaru Games、BigBox VR。


根据Steam VR平台的统计数据,我们可以看到OcQ设备的市场占有率是非常的高:



那么目前在这个市场上我们可以看到,整个平台已经有了一定数量的相关的VR内容,包括支持各个设备的,但是还仅仅是在以千为单位。



我们都知道,比如说苹果的App Store或者是安卓商城上的应用,都已经是突破上百万甚至几百万个,那么现在目前 VR的应用还处于非常的早期。


所以对于Meta来说,也就对于之前的Facebook来说,最重要的事情就是上个月28号扎克伯格宣布它正式更名成Meta,从此迈向未来的星辰大海。


微软


同样是元宇宙,我们可以看到Meta,也就是Facebook它更偏重于其社交属性。而对于微软来说,它更多是从企业办公、企业生产力方面来看,也就是所谓的“企业元宇宙”。


2015的开发者大会,微软与WIN10一起推出黑科技产品HoloLens;


2019年2月的MWC(世界移动通信大会)上,微软发布HoloLens 2代;


2021年4月,微软拿下美军218.8亿美元的军工版HoloLens合同。


除设备之外,


2018年10月,微软首次启动AzureDigital Twins平台预览版;


2020年12月,微软宣布AzureDigital Twins全面上市;


2021年3月,微软推出了一款具有3D化身和其他XR功能的虚拟平台Mesh,旨在打造能让人们通过AR/VR技术进行远程协作的应用。微软团队将会推出全新的3D虚拟化身,无须使用VR/AR头盔,用户将能够以虚拟任务或动画卡通的形式出现在视频会议中,且通过人工智能能够解读声音,让头像变得活灵活现。


可以看到微软的动作也是不断的加快:11月2日,微软在Ignite大会上宣布,计划将旗下聊天和会议应用Microsoft Teams打造成元宇宙,把混合现实会议平台Microsoft Mesh融入Microsoft Teams中。此外,Xbox游戏平台将来也要加入元宇宙。


萨提亚·纳德拉表示,微软的元宇宙最初专注于企业级应用。


微软(中国)首席技术官官韦青表示,没必要去纠结现在流行的技术叫什么词,无论是叫元宇宙也好,叫数字孪生也罢。永远不要忘记,创造虚拟空间的初衷是为了强化物理世界,让我们在现实生活提高生产效率,降低生产成本。


官韦青指出,像微软、苹果等科技公司的业务是虚拟空间、物理世界两方面业务皆有覆盖,两方面互补,而不是单方面地陷入到某一个领域。元宇宙构筑的逻辑,都是将物理世界的对象和现象变成模型,放到虚拟空间中,进行仿真、预测,最终反馈到物理空间,来强化我们的物理世界。


下图是微软的Metaverse解决方案,包括它的物理世界、连接、建模、位置、数据,还有智能逻辑以及协作平台等等,可以看到它是偏向于提高生产力。



下图是微软去年在AR/VR领域的专利,可以看到在Q1至Q3它都是排在第一的,Q4是Magic Leap跃居第一。



在Meta的眼中,元宇宙可能更多的是社交娱乐,也就是满足我们的精神需求,而在微软的眼中元宇宙做更多的是提升生产效率,满足物质层面的需求,那么,英伟达眼中的元宇宙又是什么呢?


英伟达-OmniVerse


英伟达提出自己的元宇宙叫OmniVerse。它在元宇宙相关的布局及相关产品:


1、NVIDIA RTX系统显卡和虚拟工作站;


2、NVIDIA CloudXR-XR串流平台(和微软Azure以及Amazon AWS开展合作,主要兼容AR和VR设备,包括不限于:1、大部分PCVR、HoloLens 2、VR一体机、支持AR的安卓和iOS设备等);


3、OmniVerse元宇宙平台-数字版老黄


2021年11月9日GTC大会再次升级Omniverse平台,发布了OmniverseAvatar和Omniverse Replicator。Omniverse Avatar是一个用于生成交互式AI化身的技术平台。它集合了英伟达在语音AI、计算机视觉、自然语言理解、推荐引擎和模拟技术方面积累的技术,为创建人工智能助手打开了大门,可以帮助处理数十亿的日常客户服务互动。Omniverse Replicator则是一种合成数据生成引擎,可以基于现有数据持续生成用于训练的合成数据。


Omniverse的门户是USD(通用场景描述)黄仁勋认为Omniverse的本质是一个数字虫洞。未来任何计算机都可以连接到Omniverse就像HTML(一种标记语言,可将网络上的文档格式统一)基于网站。


黄仁勋表示:“如何使用OmniVerse模拟仓库、工厂、物理和生物系统、5G边缘、机器人、自动驾驶汽车,甚至是虚拟形象的数字孪生,是一个永恒的主题。”


总结来说,在英伟达眼中,那么不管是叫OmniVerse,还是MetaVerse也好,它百分之八九十的功能是为了提升生产力。具体的细节大家可以去相关的英伟达的开发网站去看详细的细节(http:developer.nvidia.com/nvidia-omniverse-platform)。


苹果


苹果虽然目前还没有推出相关的产品,但是他在不断的收购相关的公司以及部署了非常多的专利。那么在各个场合其CEO库克也表达了他对元宇宙以及对虚拟现实的一些看法。


I think AR is big and profound. This is oneof those huge thing that we'll look back at and marved at the stat of it. Ithink customers are going to see it in a variety of ways anfd it feeld great toget AR going at a level that can get all of the developers behind it.


Tim Cook, Apple CEO


库克认为,AI,也就是增强现实是一个非常巨大的市场。


在苹果WWDC 2017大会上,苹果发布了AR开发工具ARKit,具备SLAM、平面检测、光照估计、环境理解、图像识别等功能;


2017年9月12日,苹果正式发布的iPhoneX系列手机中使用了A11 Bionic芯片,首次集成了神经网络引擎;以及3D结构光技术FaceID,通过iPhone X的Face ID可以制作3D表情Animoji;


2019年9月11日,苹果发布的iPhone11首次使用了UWB超宽频芯片U1,超宽频技术让iPhone 11系列更具空间感知能力,可精确定位其他配备U1的苹果设备;


2019年10月29日,苹果发布的AirpodsPro无线降噪耳机首次使用了“空间音频”功能,2020年9月苹果发布的iOS 14为AirPods Pro新增了“空间音频”功能;


2020年1月14日,苹果推出USDZ3D格式转换工具Reality Converter;2020年3月18日,苹果官网发布了iPad Pro 2020,首次使用了dTOF激光雷达(LiDAR);


2021年4月21日,苹果春季发布会上推出的iPad Pro 2021搭载M1芯片,令世人震惊;


2021年秋季发布会,苹果推出搭载M1X芯片的14寸和16寸Macbook Pro;


根据彭博社的报告透露,苹果未来的AR/VR设备将集成M系列芯片的高端版本。


那么产业链的消息是2022年的秋季,苹果很可能会发布自己的首款AR/MR头显。与此同时2025年的时候有可能会推出苹果首款AI眼镜。


当然前面也提到了,其实苹果虽然没有推出产品,但是它已经布局了非常多的专利,包括收购了大大小小的各种相关的公司,其实都是公开可以查询到的(http://www.fastscience.tv/collections…


谷歌


看完苹果之后,我们再来看一下谷歌。谷歌在这个领域的布局和定位,可能是没有那么的清晰。比如说我们都知道,Meta的定位是做社交元宇宙;微软做的就是企业元宇宙;苹果面向于 C端消费者市场,定位是做增强现实。


谷歌做了很多尝试性的工作,包括Google DayDream和Google Glass等,但是延续性都不是很强。所以对于谷歌今后将推出什么样的产品,我们无从知晓,目前来说延续性比较好的是ARCORE这一块。


当然它在相关技术的前沿研究上还是做的比较到位的,比如Project Starline,就是一个仿真、全新的社交。“Project Starline”是一个结合了硬件和软件技术进步的技术项目,旨在帮助相隔两地的朋友、家人和同事共聚一起。想象一下,透过一扇神奇的窗户,你可以看到另一个人,真人大小,三维形式。你们可以自然地对话,做手势和进行眼神交流。


华为


2019年推出VR Glass;2021年11月17日推出VR Glass 6dof 游戏套装版本;


2019年11月开源数据虚拟化引擎华为河图Cyberverse, 目的是打造一个“地球级、不断演进,与现实无缝融合的数字新世界”。华为河图有四个核心能力:1、3D高精地图能力;2、全场景空间计算能力;3、强环境理解功能;4、虚拟现实融合渲染能力。


其他公司


**字节跳动:**2021年8月29日字节跳动官宣90亿元人民币收购Pico。


腾讯:提出全真互联网概念。当然他在这个领域的更多是通过投融资投资来布局,比如说投资虚幻引擎,以及做上周又投了一家做触觉手套相关技术的公司。


HTC:


2015年3月在MWC 2015上发布HTC Vive,并于2016年上市;


2017年HTC 将部分手机业务出售给Google后,全面转型VR市场,曾一度占领市场先机。但是近两年C端市场的表现远远落后于Facebook,2021年6月宣布重心转向B端;


2021年5月发布HTC VIVEFocus3商业版和HTC VIVE PRO 2。


Sony:


2016年10月,Sony正式开始发售PSVR,并搭配PS4和下一代的PS5使用;2018年8月,PSVR销售突破了300万台;根据Sony官方透露的消息,PSVR2预计2022年发布


元宇宙百科词典


我们看完了科幻影视作品里面元宇宙,以及巨头对元宇宙之后的看法,接下来我们就看几个关键的核心的名词。


首先是3个R:



VR=一切皆梦幻泡影


VR(Immersive Virtual Reality)= 虚拟世界,沉浸式虚拟现实,忘了现实世界的一切~


VR满足3个特性,分别是沉浸、交互和想象。



AR=向左是真实,向右是虚幻


AR(Augmented Reality)=真实世界 + 数字化信息


MR=真实虚幻傻傻分不清


MR(Mixed Reality)=真实世界 + 虚拟世界+ 数字化信息,假作真时真亦假,无为有处有还无


**数字人:**什么是数字人,什么是虚拟偶像?通过建模、3D扫描以及动作捕捉,把类似真实的人的形象做成一个虚拟的数字人,然后让他做很多相关的初步动作。待会我们会在技术环节给大家讲述数字人是如何实现的。


**数字孪生(Digital Twin):**虚拟和现实的高度融合互通(现实世界的数字复刻)


1.最早用于NASA阿波罗项目,对飞行中的空间飞行器进行实时仿真;


2.实现物理工厂/系统和数字工厂/系统的交互和融合;


3.面向B端-用于工业4.0、智能制造、智慧城市等;


4.AR/VR、IoT、AI是重要的技术支撑。


下图是北京航空航天的陶飞等人从车间组成的角度给出了车间数字孪生的定义,然后提出了车间数字孪生的组成,主要包括:物理车间、虚拟车间、车间服务系统、车间孪生数据几部分。物理车间是真实存在的车间,主要从车间服务系统接收生产任务,并按照虚拟车间仿真优化后的执行策略,执行完成任务;虚拟车间是物理车间的计算机内的等价映射,主要负责对生产活动进行仿真分析和优化,并对物理车间的生产活动进行实时的监测、预测和调控;车间服务系统是车间各类软件系统的总称,主要负责车间数字孪生驱动物理车间的运行,和接受物理车间的生产反馈。



**全真网:**马化腾于2020年底在腾讯集团官方年度特刊《三观》提出


1.移动互联网的接替者;


2.虚拟世界和真实世界的全面融合;


3.全面+真实(全面= 消费互联网+产业互联网 真实= AR/VR交互技术)。


**元宇宙:**源自科幻作品《雪崩》,⼀个⼈们以虚拟形象在三维空间与各种软件进⾏交互的世界。其真正为人所知是今年Roblox上市的时候,把MetaVerse加到了招股说明书,并且提出了元宇宙的八大要素:身份、朋友、沉浸感、低延迟、多元化、随地、经济系统、文明。



可以看出,其对元宇宙的理解,更多也是一个社交娱乐层面的。而维基百科之中,对元宇宙的定义更加的精准和全面:The metaverse (a portmanteau of "" and"universe") is a hypothesized iteration of the internet, supportingpersistent online 3-D virtual environments through conventional personalcomputing, as well as virtual and augmented reality headsets.


一句话概述:元宇宙就是下一代互联网。


元宇宙的技术基础


那么接下来我们来一起看一看元宇宙就是如何构建的,也就是元宇宙的技术基础。


元宇宙的构成技术是非常的多,包括虚拟现实、区块链、AI+人工智能等等.



我们本次重点从虚拟现实和大家分享一下。AR/VR技术的科技树,也就是五大核心技术:近眼显示技术、内容创建技术、网络传输技术、渲染技术、感知和自然交互技术。



1.Near-eye display(近眼显示技术)包括传统的屏幕显示技术(LCOS/OLED/可折叠的AMOLED/Micro LED)和光学技术(光场显示/波导技术等等。


2.Content creation(内容创作技术)包括虚拟角色和场景构建、动作捕捉、全景视频拍摄与编辑等等。


3.Network communication(网络传输技术)


这方面最受人关注的当然就是即将商用的5G技术,以及传说中传输速率可达每秒1T的下一代6G技术了。当然还有一系列的其它技术有待发展。


4.Rendering Processing(渲染技术)包括本地渲染、云渲染、光场渲染、多重视角渲染,以及硬件渲染加速等等技术。


5.Perception&interaction(感知和自然交互)


包括跟踪定位技术、多感官自然交互技术(脑波、语音交互、触感交互等等)、机器视觉技术(SLAM/场景分离与识别等)


AR/VR技术有一个非常著名的科技树(如下图),我们可以看到刚才提到的五大技术都有一个科技树展开,然后每个树也有自己的树干,每个树干上也有非常多的分支和树叶。毫不夸张的说,在其中的任何一个树干,甚至任何一个树叶之上,如果去做深入的研究,都可以在这个领域成为一个非常资深的专家。



下图是AR/VR技术成熟度曲线,可以看到类似跟踪定位、液晶屏显示、云渲染以及OS相关等技术基本上都是属于两年之内可以商用的。


类似另外一些,比如说自由曲面、虚拟化身、混合云渲染等这些可能要2~5年。



接下来我们快速的带大家来一起过一下5大核心技术。


近眼显示技术


首先是**近眼显示技术。**近眼显示技术分两个部分,分别是显示技术以及光学技术。


显示技术其实就指的各种各样的显示屏,比如LED、MicroLED等等。



而对于光学技术,我们可能首先想到就是双眼视差原理。人眼是如何实现立体视觉呢?其实最简单就是因为每个人都有两只眼睛,每个眼睛之间都有一定的间隔,通过间隔每个眼睛看到的图像有所差别,再通过我们的大脑的这种判断,最终都形成了一个立体视觉。



同时,AR/VR的光学系统,包括 Pancake,折返式、自由曲面以及光波导。


然后我们再来看一下还有全息投影技术,3D全息投影技术可以分为投射全息投影和反射全息投影两种,是全息摄影技术的逆向展示。


目前我们经常看到的各类表演中所使用的全息投影技术都需要用到全息膜这种特殊的介质,而且需要提前在舞台上做各种精密的光学布置。虽然看起来效果绚丽无比,但成本高昂,操作复杂,需要专业训练,并非每个普通人都可以轻松享受到的。从某种程度上来说,目前的主流商用全息投影技术只能被称作“伪全息投影”。


内容创建技术


内容创建技术分成360全景拍摄、传统3D建模和3D重建。


全景拍摄,其实也就是全景相机还有全景摄像机。


优点:百分百真实


缺点:无法切换焦点,无法和场景及人物互动


3D建模就是大家熟悉的3D MAX、玛雅等。


优点:精度高,流程成熟


缺点:耗费大量人力、时间、精力


3D重建主要是针对于小物体以及人物角色。它本身就分成基于2D图像、基于3D扫描、基于红外TOF。


惯性动作捕捉技术也是比较主流的动作捕捉技术之一。其基本原理是通过惯性导航传感器和IMU(惯性测量单元)来测量演员动作的加速度、方位、倾斜角等特性。惯性动作捕捉技术的特点是不受环境干扰,不怕遮挡,采样速度高,精度高。2015年10月由奥飞动漫参与B轮投资的诺亦腾就是一家提供惯性动作捕捉技术的国内科技创业公司,其动作捕捉设备曾用在2015年最热门的美剧《冰与火之歌:权力的游戏》中,并帮助该剧勇夺第67届艾美奖的“最佳特效奖”。


我们以英伟达的“虚拟发布会”为例,来讲一下怎么搭建一个虚拟场景。


详细的步骤可以参考下图:



第一步:使用3D扫描构建虚拟场景



第二步:使用体积摄影进行全身3D建模



第三步:使用AI Audio2Face让口型和面部肌肉变化随语音变动



第四步:使用动作捕捉获取身体姿态动画



第五步:使用RTX渲染器进行实时光线追踪



在了解前述知识点之后,我们再来看看3D引擎和SDK技术。当然在这个领域AR/VR里面最常用的3D引擎无非也就是虚幻和Unity。


在此,推荐一本非常经典的书叫《游戏引擎架构》。书里对游戏引擎,从低阶到图形动画,再到高阶的构成做了非常详细的描述和解释说明,目前已经是出到第三版了。



AR/VR相关的SDK比如说Vuforia、APPLE ARKit,GOOGLEARCORE等等。


网络传输技术


我们再来看一下网络传输技术。网络传输技术是虚拟现实的支撑技术。


渲染技术渲染技术包括本地渲染、云渲染、光场渲染、多重视角渲染,以及硬件渲染加速等等技术。


本地的VR渲染流程如下:



云VR渲染流程:


在本地VR渲染的基础上额外增加三个环节(比本地渲染增加20ms左右的延迟):


1.图像压缩编码


2.网络传输


3.图像解压缩


云VR渲染的利弊:


好处:


1.降低对本地硬件处理能力的要求


包括存储空间、性能、散热等,从而让设备增加轻便


缺点:


1.额外增加延迟,影响实际体验


2.清晰度经压缩和传输后无法保证


端云渲染的配合使用


1.对于不追求及时响应的应用


如3dof游戏、VR看房、旅游景点观赏、全景视频播放等,通过ATW+云渲染+本地观看的方式可以获得比较好的效果。


2.对于追求及时响应的6dof游戏和社交互动应用


渲染处理更多还是需要在本地进行,云端用于处理指令型数据(参考大型多人在线游戏MMORPG)。


3.当前的5G网络和设备硬件性能无法支撑强互动型的云VR渲染和数据传输,未来的6G可以完全实现。


感知和自然交互技术


Inside-out技术


基于单目/双目/多目视觉+IMU的inside-out技术取代早期的Outside-in技术开始产品化,特别是在VR一体机设备,如Oculus Quest /Oculus Quest 2,HTC Vive Focus等。


可以实现:


1.追踪定位


2.手势动作识别


FOV眼动追踪技术


眼动追踪的原理其实很简单,就是使用红外摄像头和LED捕捉人眼或脸部的图像,然后用算法实现人脸和人眼的检测、定位和跟踪,从而估算用户的视线变化。目前主要使用光谱成像和红外光谱成像两种图像处理方法,前一种需要捕捉虹膜和巩膜之间的轮廓,而后一种则跟踪瞳孔轮廓。


SLAM


基于RGBD相机和红外TOF、激光雷达和AI算法等实现实时场景3D重建,在机器人、无人机和AR/VR设备如HoloLens中得到普遍应用,


除此之外,还有语音交互和语义理解、触觉反馈,嗅觉及其它感觉及模拟器。


另外还有一个非常亮的亮点——脑机接口(大脑和计算机直接进行交互,有时候又被称为意识-机器交互,神经直连。脑机接口是人或者动物大脑和外部设备间建立的直接连接通道,又分为单向脑机接口和双向脑机接口)。


单向脑机接口只允许单向的信息通讯,比如只允许计算机接受大脑传来的命令,或者只允许计算机向大脑发送信号(比如重建影像)。而双向脑机接口则允许大脑和外部计算机设备间实现双向的信息交换。



如何参与元宇宙


作为开发者也好,作为兴趣者也好,我们如何来参与元宇宙呢?


未来5年产业发展预测


首先看一下产业链的构成,它包括硬件、平台、工具、内容、行业应用,还有服务。


硬件有分很多分支,比如终端设备,还有其中的零部件等等,其中:


1.2022年将是AR/VR行业真正爆发的元年,特别是VR;


2.VR设备从2021年OCQ2代单款突破千万销量后,将开始爆发式增长;


3.苹果新品将让AR/VR从小众精英人群的玩具走向大众,其行业影响力不容小觑;


4.终端设备从2022年开始将成为巨头逐鹿的市场,小型创业团队的窗口期接近关闭,从2021年下半年开始会看到更为密集的战略型投融资或并购事件发生;


5.AR设备在接近诸多技术问题之前,主要仍将面向2B市场,在2025年可能迎来爆发;


6.核心器件方面(芯片、显示屏、光学器件、声光电传感器等模组)投入巨大,不适合初创型团队,目前仍然是巨头以及上市公司体量团队的天下。但该部分也是构成终端设备比例最大的部分;


7.感知交互方面,目前并没有统一的行业标准,空间定位、手势交互、眼动追踪、全身动捕、语音交互、脑机交互都处于发展的早期阶段,该领域有众多的初创企业。而Facebook、苹果等公司收购的重点也在该领域的领先技术团队;


8.其它配套外设,目前全景相机领域已有脱颖而出的领先者,如Insta360,其它领域因生态系统尚未标准化,有较大的空间。


工具平台


1.工具平台中的系统级平台(操作系统/UI)仍将由巨头把控,特别是苹果、Facebook,国内厂商如能接受移动互联网时代的教训,应在第一时间切入底层系统平台的打造,否则仍将受制于人


2.AR/VR内容创建工具目前虽然已经有Unity/UE4等市场领先产品,但是因为设备平台的独特性,仍然有巨大的潜力空间,包括SDK、3D开发引擎、基于AI技术的自动化3D场景和角色建模工具、基于AI技术的高效渲染软件等,都有足够的空间。该部分也给初创型团队留下了足够的机会。


内容-内容创作


随着三大核心产品的爆发,以及C端的量级突破,内容创作方面将迎来全面繁荣,包括影视、游戏、直播、社交、3D/全景等。而内容创作因为其创意和开放性属性,一向是初创团队的首选,在硬件和工具平台领域形成各自王者之后,将有越来越多的团队加入该领域。内容创作和工具平台的交集是类似Roblox的“元宇宙”大型多人在线社交类产品。


内容-内容分发


1.系统级别的内容分发和流量入口仍将占据首要地位,特别是后续的苹果生态


2.类似移动互联网的安卓商城生态,将有众多的第三方内容分发平台涌现,比如专门针对Oculus Quest的SideQuest平台。3.类似于移动互联网时代的微信,后续也将有类似微信的超级APP出现,同样可以扮演内容分发的角色。


行业应用


1.在国内市场,行业应用领域短期内仍将是VR的主要商业变现应用场景,如面向职业教育的教育培训、医疗健康、军事训练等。


2.在可预见的5年周期内,AR的主要应用场景仍然集中在行业领域,特别是智能制造、数字孪生等。


服务


随着行业的爆发式增长,相关的媒体、协会、线下活动等也会更加活跃起来,并逐渐形成集媒体、投融资服务、产品推介等为一体的综合服务,类似移动互联网时代的36kr等。部分媒体也会朝内容分发的方向去尝试。


总结


我们见证了历史,也步入了未来。人人皆可改变世界。



作者:华为云开发者社区
链接:https://juejin.cn/post/7034698377270919198
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

(转载)运营人的元宇宙

元宇宙是什么?是虚拟现实,是现代人逃离现状的美好乌托邦,又或是一个顶级的智商税。到底是什么,我们不做过多深究,既然名字叫元宇宙,那多半类似互联网黑话中的底层逻辑一样,说的人不太明白,听的人更是一头雾水,因为皇帝新装的缘故,没人敢指出来其中的疑惑,这确实太像智商...
继续阅读 »

元宇宙是什么?是虚拟现实,是现代人逃离现状的美好乌托邦,又或是一个顶级的智商税。

到底是什么,我们不做过多深究,既然名字叫元宇宙,那多半类似互联网黑话中的底层逻辑一样,说的人不太明白,听的人更是一头雾水,因为皇帝新装的缘故,没人敢指出来其中的疑惑,这确实太像智商税了

\

01

运营人的元宇宙之美好幻想

元宇宙更像黑客帝国,在残酷现实中构筑美好的幻想世界,与黑客帝国不同的是,现实中的虚拟现实你可以自由设定世界观,你就是自己元宇宙的国王,挥挥手建立起自己的王国。

确实让人充满期待,类似塞尔达那样的开放世界,你可以做任何想做的事,飞天、遁地啥的

运营人的美好幻想是什么?做的每个策略都取得百分百的效果,每个月都完成kpi,篇篇爆文,随便拍的一个视频点赞破百万,直播带货在线10万加,自己主导的项目干翻互联网大厂,成为互联网又一极。

这一切能发生吗?在元宇宙里还真的能发生,就像魔兽争霸里调秘籍一样,有无限的资源可以用,我就是神一样的存在。

在运营人元宇宙中,用户为你所用,资源为你所调用,总之一句话,在虚拟现实世界中,一切都是你说的算

上面说的是幻想,现实中能不能实现呢?其实还是有可能梦想成真的,我说的那些成绩,现实中不乏能做到的人,既然别人能做到,我们当然也有做到的可能。

如何让自己在现实中变的牛掰起来,多看,多做,多思考。能做到这三点,你就是现实中的王者

虚拟现实毕竟不能当饭吃,练好现实中的技能才是主要的,虽然说有的人已经开始利用元宇宙概念割韭菜了,这种行为不提倡,但是这种与时俱进,奋斗不止的精神是大家值得学习的。

问问自己,这个月学会了什么新技能,相比去年能力有没有提升,现实宇宙中务必要做到每天进步一点点,这样即使元宇宙摆在你面前,你也不会想进去,因为现实宇宙中你过得很不错。

\

02

运营人的元宇宙之面对现实

就像网络上给父母的一句忠告:你一定要清晰的认知到,你的孩子未来也就是普罗众生中普普通通的一员。接受这个现实,你或许能过的更幸福。

我接触的一些运营人也是存在很多幻想,动不动颠覆行业,融资千万,那都是在元宇宙里面才能发生的事。

现实中还是要脚踏实地,做好眼下手头的工作才是重要的,当你把工作都做好了,也许升职加薪就找上门来了

大家追逐元宇宙,主要是元宇宙的美好幻想,能让人逃避现实,想想沉溺网络游戏的玩家,在游戏中是叱咤一方的领主,万人敬仰。

现实中根本没人理睬,活在虚拟现实中就变成了这些人的刚需,运营人谁不想任何事情都心想事成,但是生活的本质是幸福与磨难并行,没有任何一个人一生都是顺风顺水的。

看看互联网头部人物,有的流年不利,有的晚节不保,有的三振出局,前半生光鲜,后半生凄惨的例子简直太多,这就是生活

我们要认识到生活就是如此,幻想可以留在晚上做梦时做,也可以寄托于元宇宙中,不过要知道作为碳基生物,吃饭是永远离不开的话题,一日三餐还是要在现实生活中解决。

有的人每餐吃生猛海鲜,有的人吃沙县拉面,这是很正常的,你能带来多大价值,就会获得多少回报,你的回报决定了你的生活水平,不怨天尤人,勤勉待人,这就是你应该做的。

\

03

运营人的元宇宙之割与被割

元宇宙是不是智商税我不知道,有些人已经被割韭菜了我倒是知道的,运营人一般都比较聪明,割韭菜的镰刀很难割到。

同样在线付费课程,非运营行业付费率老高,到了运营这,很难收到钱,有人得出运营人比较穷的结论,我倒不觉得,毕竟运营岗的工资还可以。

其实就是运营人猴精猴精的不好割,咱们什么套路没见过,有的割韭菜套路还是运营发明的,始作俑者运营人也。

这里我想跟所有的互联网人提个醒,元宇宙说不准是西方割东方互联网公司的大镰刀,还记得苏联是如何被漂亮国的虚假星球大战计划拖垮的吗?

当我看到国内几乎所有互联网企业抢注元宇宙商标时,直觉告诉我,这些企业要被割了。

虽然外国的互联网比较发达,但是移动互联网却被我们远远甩在身后,移动支付、网购、短视频厉害的不要不要的,我要是外国互联网公司,技术上拼不过,那肯定要从其他地方想办法。

比如编织一个虚无概念给其他人跳,等你投入了巨量资金,发现人家一开始就没跟时,你不仅白白浪费了百亿千亿资金,更主要的是错过了有价值技术的窗口期

最近也有官方声音出来给元宇宙降降了温,为了大家的财产安全真是操碎了心。

别人公司改个名字就掀起了如此大的波澜,浑水摸鱼者有之,推波助澜者有之,被洗脑者亦有之,你属于哪一类,只能自己去判断了

退一步讲,元宇宙假如真能实现,每个人都能在虚拟世界中畅快的生活,请问,现实中住哪里?如何培育下一代?工资找谁领?

当面对这些具体的问题时,你会发现与其操心元宇宙的未来,不如想想明天午饭吃什么来的实际。

\

04

最后

从心里讲,我还是很希望元宇宙能成为现实的,咱好歹也是游戏行业出身的人儿,元宇宙差不多算是加强版的vr游戏,有体感,有触觉,一切变的那么真实,多嗨皮的

不过技术的成熟需要时间,几年内是不可能的,几十年后也许能成为现实,这让我想起了盛大,当初执意做娱乐盒子,结果没做起来,是方向错了吗?不是的。

后来腾讯给做起来了,失败的原因是什么呢?那时候网络条件还不具备,做的太早了,成了先烈,元宇宙的未来也许是好的,但眼下我觉得不是,这就是我对元宇宙的粗浅理解吧!

作者:老虎讲运营,运营岗书籍《全栈运营高手》作者,运营推广大牛,千万流水项目操盘手,专栏作家,专注产品运营推广,擅长品牌打造和爆款制造,号称运营推广老司机。

原文链接:https://juejin.cn/post/7033417428965163022

收起阅读 »

重新审视前端模块的调用, 执行和加载之间的关系

在进入正题之前, 让我们先回顾下前端模块从无到有的一个简短历史 如果你有一定的工作经验, 并且经历过 jQuery 那样的年代, 应该了解早期的前端模块, 只是 window 上的一个局部变量. 在最初的时候前端工程师为了分享自己的代码, 往往会通过 wind...
继续阅读 »

在进入正题之前, 让我们先回顾下前端模块从无到有的一个简短历史


如果你有一定的工作经验, 并且经历过 jQuery 那样的年代, 应该了解早期的前端模块, 只是 window 上的一个局部变量.


在最初的时候前端工程师为了分享自己的代码, 往往会通过 window 来建立联系, 这种古老的做法至今还被很多人使用, 因为简单. 例如我们编写了一个脚本, 通常我们并不认为这是个模块, 但我们会习惯于将这个脚本包装成一个对象. 例如


window.myModule = {
getName(name){
return `hello ${name}`
}
}

当其他人加载这个脚本后, 就可以便捷的通过 window.myModule 来调用 getName 方法.


早期的 JavaScript 脚本主要用于开发一些简单的表单和网页的交互功能, 那个年代的前端工程师数量也极少, 通过 window 来实现模块化并没有什么太大的问题.


直到 ajax 的出现, 将 web 逐步推动到了富客户端的阶段, 随着 spa 的兴起, 前端工程师发现使用 window 模块化代码越来越难以维护, 主要原因有 2 个



  1. 大量的模块加载污染了 window, 导致各种命名冲突和意外覆盖, 这些问题还很难定位.

  2. 模块和模块之间的交互越来越多, 为了保证调用顺序, 需要人为保障 script 标签的加载顺序


为了解决这个问题, 类似 require seajs 这样的模块 loader 被创造出来, 通过模块 loader, 大大缓解了上述的两个问题.


但前端技术和互联网发展的速度远超我们的想象, 随着网页越来越像一个真实的客户端, 这对前端的工程能力提出了极大的挑战, 仅靠单纯的脚本开发已经难以满足项目的需要, 于是 gulp 等用于前端工程管理的脚手架开始进入我们的视野, 不过在这个阶段, 模块 loader 和前端工程流之间尚未有机的结合.


直到 nodejs 问世, 前端拥有了自己的包管理工具 npm, 在此基础上 Webpack 进一步推动了前端工程流和模块之间的整合, 随后前端模块化的进程开始稳固下来, 一直保持至今.


从这个历史上去回顾, 前端模块化的整个进程包括 es6 关于 module 的标准都是一直围绕这个一个核心命题存在的.


无论是 require 还是 Webpack 在这个核心命题上并没有区别, 即前端模块遵循


加载 → 调用 → 执行 这样的一个逻辑关系. 因为模块必须先加载才能调用并执行, 模块加载器和构建工具就必须管理和分析应用中所有模块的依赖关系, 从而确定哪些模块可以拆分哪些可以合并, 以及模块的加载顺序.


但是随着时间的推移, 前端应用的模块越来越多, 应用越来越庞大, 我们的本地的 node_modules 几百兆起步, Webpack 虽然做了很多优化, 但是 rebuild 的时间在大型应用面前依然显得很慢.


今年 2 月份, Webpack 5 发布了他们的模块拆解方案, 模块联邦, 这个插件解决了 Webpack 构建的模块无法在多个工程中复用的问题.


早些时间 yarn 2.0 采用共享 node_moudles 的方法来解决本地模块大量冗余导致的性能问题.
包括 nodejs 作者在 deno 中放弃了 npm 改用网络化加载模块的方式等等.


可以看到社区已经意识到了先行的前端模块化机制再次面临瓶颈, 无论是性能还是维护成本都面临诸多挑战, 各个团队都在想办法开辟一个新的方向.


不过这些努力依然没有超越先行模块化机制中的核心命题, 即模块必须先加载, 后调用执行.


只要这个核心命题不变, 模块的依赖问题依然是无解的. 为此我们尝试提出了一种新的思路


模块为什么不能先调用, 后加载执行呢?


如果 A 模块调用 B 模块, 但并不需要 B 模块立即就绪, 这就意味着, 模块加载器可以不关心模块的依赖关系, 而致力于只解决模块加载的效率和性能问题.


同时对于构建工具来说, 如果 A 模块的执行并不基于 B 模块立即就绪这件事, 那么构建工具可以放心的将 A 和 B 模块拆成两个文件, 如果模块有很多, 就可以利用 http2 的并行加载能力, 大大提升模块的加载性能.


在我们的设想中, 一种新的模块加载方式是这样的


// remoteModule.js 这是一个发布到 cdn 的远程模块, 内部代码是这样

widnow.rdeco.create({
name:'remote-module',
exports:{
getName(name, next){
next(`hello ${name}`)
}
}
})


让我们先不加载这个模块, 而是直接先执行调用端的代码例如这样



window.rdeco 可以理解成类似 Webpack runtime 一样的存在, 不过 rdeco 是一个独立的库, 其功能远不止于此



// localModule.js 这个是本地的模块
window.rdeco.inject('remote-module').getName('world').then(fullName=>{
console.log(fullName)
})

然后我们在 html 中先加载 localModule.js 后加载 remoteModule.js


<scirpt src="localModule.js"></script>
<scirpt src="remoteModule.js"></script>


正常理解, localModule.js 加载完之后会试图去调用 remote-module 的 getName 方法, 但此时 remoteModule 尚未加载, 按照先行的模块化机制, 这种调用会抛出异常. 为了避免这个问题


模块构建工具需要分析两个文件的代码, 从而发现 localModule.js 依赖 remoteModule.js, 然后保存这个依赖顺序, 同时通知模块加载器, 为了让代码正常执行, 必须先加载 remoteModule.js.


但如果模块可以先调用后加载, 那么这个复杂的过程就可以完全避免. 目前我们实现了这一机制, 可以看下这个 demo: codesandbox.io/s/tender-ar…


你可试着先点击 Call remote module's getName method 按钮,


此时文案不会变化只是显示 hello, 但代码并不会抛出异常, 然后你再点击 Load remote module 按钮, 开始加载 remoteModule, 等待加载完成, getName 才会真实执行, 此时文案变成了 hello world



作者:掘金泥石流
链接:https://juejin.cn/post/7034412398261993479

收起阅读 »

CSS实现随机不规则圆角头像

 前言 最近真是彻底爱上了 CSS ,我又又又被 CSS 惊艳到了,明明是简单的属性,为啥大佬们稍微一组合,就能形成如此好看的效果啊。本文 给大家带来的是随机不规则圆角头像效果,我们可以把这个效果用于一些人物的展示页面 学习本文章,你可以学到:bor...
继续阅读 »

 前言


最近真是彻底爱上了 CSS ,我又又又被 CSS 惊艳到了,明明是简单的属性,为啥大佬们稍微一组合,就能形成如此好看的效果啊。本文


给大家带来的是随机不规则圆角头像效果,我们可以把这个效果用于一些人物的展示页面


学习本文章,你可以学到:

  • border-radius 实现椭圆效果
  • border-radius 实现不规则圆角头像
  • animation-delay 设置负值
  • 实现随机不规则圆角

📃 预备知识


🎨 border-radius


border-radius 可以设置外边框的圆角。比如我们经常使用的 border-radius: 50% 可以得到一个圆形头像。


radius50.png


border-radius 就只能实现圆形效果吗?当然不是,当使用一个半径是确定圆形,两个半径时则会确定椭圆形。


光说不练假把式,接下来一起试试



  1. 设置 border-radius: 30% 70%,就可以得到椭圆效果


radius3070.png


上面的设置都是针对于四个方向的,也可以只设置一个方向的圆角



  1. 设置 border-top-left-radius: 30% 70%


radius3070top.png


从上图其实可以得出,两个值分别设置水平半径和垂直半径的半径,为了更准确我们验证一下


radiusopa.png


但为啥设置的圆角与 border-radius: 30% 70% 设置有这么大的差距。别急,下面慢慢道来。



  1. 设置 border-radius: 30%/70%,/ 前后的值分别为水平半径和垂直半径



border-radius: 30%/70% 相当于给四个方向都设置 30%/70%,而 border-radius: 30% 70% 是给左上右下设置 30% ,左下右上设置 70%



radius30-70.png



  1. 设置四个方向为四种椭圆角: border-radius: 40% 60% 60% 40% / 60% 30% 70% 40% ,就可以实现简单的不规则圆角效果,小改改的头像是不是看起来舒服了好多。


radiusdisorder.png


💞 animation-delay


animation-delay: 可以定义动画播放的延迟时间。


但如果给 animation-delay 设置负值会发生什么那?



MDN 中指出: 定义一个负值会让动画立即开始。但是动画会从它的动画序列中某位置开始。例如,如果设定值为 -1s ,动画会从它的动画序列的第 1 秒位置处立即开始。



那个,乍看上去,我好像懂了,又好像没懂,咱们还是来自己试一下吧。



  • 创建 div 块,宽高都为 0 ,背景设置为 #000

  • 添加 keyframe 动画,100% 状态宽高都扩展为 1000px


@keyframes extend {
0% {
width: 0;
height: 0;
}
100% {
width: 1000px;
height: 1000px;
}
}


  • div 添加 animationanimation-delay


/* 设置 paused 可以使动画暂停 */
animation: extend 10s linear paused;
animation-delay: -3s;

当我打开浏览器时,浏览器出现 300*300 的黑色块,修改 animation-delay-4s ,浏览器出现 400*400 的黑块。我们使用 linear 匀速作为动画播放函数,10s 后 div 会变为 1000px,设置 -3s 起始为 300px-4s 起始为 400px


这样一对比,我们来把 MDN 的描述翻译一下:
+ animation-delay 设置负值的动画会立即执行
+ 动画起始位置是动画中的一阶段,比如上述案例,定义 10s 的动画,设置 -3s 动画就从 3s 开始执行


🌊 radius 配合 delay 实现


有了上面基础知识的配合,不规则圆角的实现就变得很简单了。


设置 keyframekeyframe 的开始与结束为两种不规则圆角,再使用 :nth-child 进行自然随机设置 animation-delay 的负值延迟时间,就可以得到一组风格各异的不规则圆角效果



自然随机的算法非常有意思,效果开创者为了更好、更自然的随机性,选取序列为 2n+1 3n+2 5n+3 7n+4 11+5 ...




  1. 设置 keyframe 动画


@keyframes morph {
0% {
border-radius: 40% 60% 60% 40% / 60% 30% 70% 40%;
transform: rotate(-5deg);
}
100% {
border-radius: 40% 60%;
transform: rotate(5deg);
}
}


  1. 自然随机设置每个头像的 delay


.avatar:nth-child(n) {
animation-delay: -3.5s;
}
.avatar:nth-child(2n + 1) {
animation-delay: -1s;
}
.avatar:nth-child(3n + 2) {
animation-delay: -2s;
}
.avatar:nth-child(5n + 3) {
animation-delay: -3s;
}
.avatar:nth-child(7n + 5) {
animation-delay: -4s;
}
.avatar:nth-child(11n + 7) {
animation-delay: -5s;
}

当当当当~~~ 效果就实现了! 看着下面这些风格各异的小改改,瞬间心情舒畅了好多。


avater.png


不规则圆角头像的功能实现了,但总感觉缺点什么?如果头像能有点动态效果就更好了。


例如 hover 时,头像圆角会发生变化,用户的体验会更好。


我首先的想法还是在上面的代码基础上面更改,但由于 @keyframe 定义好了终点时的状态,能变化的效果并不多,而且看起来很单调,显得很呆 🤣。


那有没有好的实现方案那?有,最终我找到了张鑫旭大佬的实现方案,大佬还是大佬啊。


🌟 radius 配合 transition 实现


参考博客: “蝉原则”与CSS3随机多背景随机圆角等效果



  1. 按照自然随机给每个头像赋予不同的不规则圆角


/* 举两个例子 */
.list:hover {
border-radius: 95% 70% 100% 80%;
transform: rotate(-2deg);
}
.list:nth-child(2n+1) {
border-radius: 59% 52% 56% 59%;
transform: rotate(-6deg);
}


  1. 设置 hover 时新的不规则圆角


.list:nth-child(2n+1):hover {
border-radius: 51% 67% 56% 64%;
transform: rotate(-4deg);
}

.list:nth-child(3n+2):hover {
border-radius: 69% 64% 53% 70%;
transform: rotate(0deg);
}


  1. list 元素配置 transition


avatar.gif


完成上面的步骤,我们就可以得到更灵动的小改改头像了。



但这种实现方法相比较于 radius 配合 animation-delay 实现具备一定的难点,需要设计多种好看的不规则圆角效果



🛕 源码仓库


传送门: 随机不规则圆角




作者:战场小包
链接:https://juejin.cn/post/7034396555738251301

收起阅读 »

使用 Promise 时的5个常见错误,你占了几个!

Promise 提供了一种优雅的方法来处理 JS 中的异步操作。这也是避免“回调地狱”的解决方案。然而,并没有多少开发人员了解其中的内容。因此,许多人在实践中往往会犯错误。 在本文中,介绍一下使用 promise 时的五个常见错误,希望大家能够避免这些错误。 ...
继续阅读 »

Promise 提供了一种优雅的方法来处理 JS 中的异步操作。这也是避免“回调地狱”的解决方案。然而,并没有多少开发人员了解其中的内容。因此,许多人在实践中往往会犯错误。


在本文中,介绍一下使用 promise 时的五个常见错误,希望大家能够避免这些错误。


1.避免 Promise 地狱


通常,Promise是用来避免回调地狱。但滥用它们也会导致 Promise是地狱。


userLogin('user').then(function(user){
getArticle(user).then(function(articles){
showArticle(articles).then(function(){
//Your code goes here...
});
});
});

在上面的例子中,我们对 userLogingetararticleshowararticle 嵌套了三个promise。这样复杂性将按代码行比例增长,它可能变得不可读。


为了避免这种情况,我们需要解除代码的嵌套,从第一个 then 中返回 getArticle,然后在第二个 then 中处理它。


userLogin('user')
.then(getArticle)
.then(showArticle)
.then(function(){
//Your code goes here...
});

2. 在 Promise 中使用 try/catch


通常情况下,我们使用 try/catch 块来处理错误。然而,不建议在 Promise 对象中使用try/catch


这是因为如果有任何错误,Promise对象会在 catch 内自动处理。


ew Promise((resolve, reject) => {
try {
const data = doThis();
// do something
resolve();
} catch (e) {
reject(e);
}
})
.then(data => console.log(data))
.catch(error => console.log(error));

在上面的例子中,我们在Promise 内使用了 try/catch 块。


但是,Promise本身会在其作用域内捕捉所有的错误(甚至是打字错误),而不需要 try/catch块。它确保在执行过程中抛出的所有异常都被获取并转换为被拒绝的 Promise。


new Promise((resolve, reject) => {
const data = doThis();
// do something
resolve()
})
.then(data => console.log(data))
.catch(error => console.log(error));

**注意:**在 Promise 块中使用 .catch() 块是至关重要的。否则,你的测试案例可能会失败,而且应用程序在生产阶段可能会崩溃。


3. 在 Promise 块内使用异步函数


Async/Await 是一种更高级的语法,用于处理同步代码中的多个Promise。当我们在一个函数声明前使用 async 关键字时,它会返回一个 Promise,我们可以使用 await 关键字来停止代码,直到我们正在等待的Promise解决或拒绝。


但是,当你把一个 Async 函数放在一个 Promise 块里面时,会有一些副作用。


假设我们想在Promise 块中做一个异步操作,所以使用了 async 关键字,但,不巧的是我们的代码抛出了一个错误。


这样,即使使用 catch() 块或在 try/catch 块内等待你的Promise,我们也不能立即处理这个错误。请看下面的例子。


// 此代码无法处理错误
new Promise(async () => {
throw new Error('message');
}).catch(e => console.log(e.message));

(async () => {
try {
await new Promise(async () => {
throw new Error('message');
});
} catch (e) {
console.log(e.message);
}
})();

当我在Promise块内遇到 async 函数时,我试图将 async 逻辑保持在 Promise 块之外,以保持其同步性。10次中有9次都能成功。


然而,在某些情况下,可能需要一个 async 函数。在这种情况下,也别无选择,只能用try/catch 块来手动管理。


new Promise(async (resolve, reject) => {
try {
throw new Error('message');
} catch (error) {
reject(error);
}
}).catch(e => console.log(e.message));


//using async/await
(async () => {
try {
await new Promise(async (resolve, reject) => {
try {
throw new Error('message');
} catch (error) {
reject(error);
}
});
} catch (e) {
console.log(e.message);
}
})();

4.在创建 Promise 后立即执行 Promise 块


至于下面的代码片断,如果我们把代码片断放在调用HTTP请求的地方,它就会被立即执行。


const myPromise = new Promise(resolve => {
// code to make HTTP request
resolve(result);
});

原因是这段代码被包裹在一个Promise构造函数中。然而,有些人可能会认为只有在执行myPromisethen方法之后才被触发。


然而,真相并非如此。相反,当一个Promise被创建时,回调被立即执行。


这意味着在建立 myPromise 之后到达下面一行时,HTTP请求很可能已经在运行,或者至少处于调度状态。


Promises 总是急于执行过程。


但是,如果希望以后再执行 Promises,应该怎么做?如果现在不想发出HTTP请求怎么办?是否有什么神奇的机制内置于 Promises 中,使我们能够做到这一点?


答案就是使用函数。函数是一种耗时的机制。只有当开发者明确地用 () 来调用它们时,它们才会执行。简单地定义一个函数还不能让我们得到什么。所以,让 Promise 变得懒惰的最有效方法是将其包裹在一个函数中!


const createMyPromise = () => new Promise(resolve => {
// HTTP request
resolve(result);
});

对于HTTP请求,Promise 构造函数和回调函数只有在函数被执行时才会被调用。所以现在我们有一个懒惰的Promise,只有在我们需要的时候才会执行。


5. 不一定使用 Promise.all() 方法


如果你已经工作多年,应该已经知道我在说什么了。如果有许多彼此不相关的 Promise,我们可以同时处理它们。


Promise 是并发的,但如你一个一个地等待它们,会太费时间,Promise.all()可以节省很多时间。



记住,Promise.all() 是我们的朋友



const { promisify } = require('util');
const sleep = promisify(setTimeout);

async function f1() {
await sleep(1000);
}

async function f2() {
await sleep(2000);
}

async function f3() {
await sleep(3000);
}


(async () => {
console.time('sequential');
await f1();
await f2();
await f3();
console.timeEnd('sequential');
})();

上述代码的执行时间约为 6 秒。但如果我们用 Promise.all() 代替它,将减少执行时间。


(async () => {
console.time('concurrent');
await Promise.all([f1(), f2(), f3()]);
console.timeEnd('concurrent');
})();

总结


在这篇文章中,我们讨论了使用 Promise 时常犯的五个错误。然而,可能还有很多简单的问题需要仔细解决。



作者:前端小智
链接:https://juejin.cn/post/7034661345148534815

收起阅读 »

没想到吧!这个可可爱爱的游戏居然是用 ECharts 实现的!

前言 echarts是一个很强大的图表库,除了我们常见的图表功能,echarts有一个自定义图形的功能,这个功能可以让我们很简单地在画布上绘制一些非常规的图形,基于此,我们来玩一些花哨的。 下面我们来一步步实现他。 1 在坐标系中画一只会动的小鸟 首先实例化一...
继续阅读 »

前言


echarts是一个很强大的图表库,除了我们常见的图表功能,echarts有一个自定义图形的功能,这个功能可以让我们很简单地在画布上绘制一些非常规的图形,基于此,我们来玩一些花哨的。


下面我们来一步步实现他。


1 在坐标系中画一只会动的小鸟


首先实例化一个echart容器,再从网上找一个像素小鸟的图片,将散点图的散点形状,用自定义图片的方式改为小鸟。


const myChart = echarts.init(document.getElementById('main'));
option = {
series: [
{
name: 'bird',
type: 'scatter',
symbolSize: 50,
symbol: 'image://bird.png',
data: [
[50, 80]
],
animation: false
},
]
};

myChart.setOption(option);

要让小鸟动起来,就需要给一个向右的速度和向下的加速度,并在每一帧的场景中刷新小鸟的位置。而小鸟向上飞的动作,则可以靠角度的旋转来实现,向上飞的触发条件设置为空格事件。


option = {
series: [
{
xAxis: {
show: false,
type: 'value',
min: 0,
max: 200,
},
yAxis: {
show: false,
min: 0,
max: 100
},
name: 'bird',
type: 'scatter',
symbolSize: 50,
symbol: 'image://bird.png',
data: [
[50, 80]
],
animation: false
},
]
};

// 设置速度和加速度
let a = 0.05;
let vh = 0;
let vw = 0.5

timer = setInterval(() => {
// 小鸟位置和仰角调整
vh = vh - a;
option.series[0].data[0][1] += vh;
option.series[0].data[0][0] += vw;
option.series[0].symbolRotate = option.series[0].symbolRotate ? option.series[0].symbolRotate - 5 : 0;

// 坐标系范围调整
option.xAxis.min += vw;
option.xAxis.max += vw;

myChart.setOption(option);
}, 25);

效果如下


GIF1.gif


2 用自定义图形绘制障碍物


echarts自定义系列,渲染逻辑由开发者通过renderItem函数实现。该函数接收两个参数params和api,params包含了当前数据信息和坐标系的信息,api是一些开发者可调用的方法集合,常用的方法有:




  • api.value(...),意思是取出 dataItem 中的数值。例如 api.value(0) 表示取出当前 dataItem 中第一个维度的数值。




  • api.coord(...),意思是进行坐标转换计算。例如 var point = api.coord([api.value(0), api.value(1)]) 表示 dataItem 中的数值转换成坐标系上的点。




  • api.size(...), 可以得到坐标系上一段数值范围对应的长度。




  • api.style(...),可以获取到series.itemStyle 中定义的样式信息。




灵活使用上述api,就可以将用户传入的Data数据转换为自己想要的坐标系上的像素位置。


renderItem函数返回一个echarts中的graphic类,可以多种图形组合成你需要的形状,graphic类型。对于我们游戏中的障碍物只需要使用矩形即可绘制出来,我们使用到下面两个类。




  • type: group, 组合类,可以将多个图形类组合成一个图形,子类放在children中。




  • type: rect, 矩形类,通过定义矩形左上角坐标点,和矩形宽高确定图形。




// 数据项定义为[x坐标,下方水管上侧y坐标, 上方水管下侧y坐标]
data: [
[150, 50, 80],
...
]

renderItem: function (params, api) {
// 获取每个水管主体矩形的起始坐标点
let start1 = api.coord([api.value(0) - 10, api.value(1)]);
let start2 = api.coord([api.value(0) - 10, 100]);
// 获取两个水管头矩形的起始坐标点
let startHead1 = api.coord([api.value(0) - 12, api.value(1)]);
let startHead2 = api.coord([api.value(0) - 12, api.value(2) + 8])
// 水管头矩形的宽高
let headSize = api.size([24, 8])
// 水管头矩形的宽高
let rect = api.size([20, api.value(1)]);
let rect2 = api.size([20, 100 - api.value(2)]);
// 坐标系配置
const common = {
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
}
// 水管形状
const rectShape = echarts.graphic.clipRectByRect(
{
x: start1[0],
y: start1[1],
width: rect[0],
height: rect[1]
},common
);
const rectShape2 = echarts.graphic.clipRectByRect(
{
x: start2[0],
y: start2[1],
width: rect2[0],
height: rect2[1]
},
common
)

// 水管头形状
const rectHeadShape = echarts.graphic.clipRectByRect(
{
x: startHead1[0],
y: startHead1[1],
width: headSize[0],
height: headSize[1]
},common
);

const rectHeadShape2 = echarts.graphic.clipRectByRect(
{
x: startHead2[0],
y: startHead2[1],
width: headSize[0],
height: headSize[1]
},common
);

// 返回一个group类,由四个矩形组成
return {
type: 'group',
children: [{
type: 'rect',
shape: rectShape,
style: {
...api.style(),
lineWidth: 1,
stroke: '#000'
}
}, {
type: 'rect',
shape: rectShape2,
style: {
...api.style(),
lineWidth: 1,
stroke: '#000'
}
},
{
type: 'rect',
shape: rectHeadShape,
style: {
...api.style(),
lineWidth: 1,
stroke: '#000'
}
},
{
type: 'rect',
shape: rectHeadShape2,
style: {
...api.style(),
lineWidth: 1,
stroke: '#000'
}
}]
};
},

颜色定义, 我们为了让水管具有光泽使用了echarts的线性渐变色对象。


itemStyle: {
// 渐变色对象
color: {
type: 'linear',
x: 0,
y: 0,
x2: 1,
y2: 0,
colorStops: [{
offset: 0, color: '#ddf38c' // 0% 处的颜色
}, {
offset: 1, color: '#587d2a' // 100% 处的颜色
}],
global: false // 缺省为 false
},
borderWidth: 3
},

另外,用一个for循环一次性随机出多个柱子的数据


function initObstacleData() {
// 添加minHeight防止空隙太小
let minHeight = 20;
let start = 150;
obstacleData = [];
for (let index = 0; index < 50; index++) {
const height = Math.random() * 30 + minHeight;
const obstacleStart = Math.random() * (90 - minHeight);
obstacleData.push(
[
start + 50 * index,
obstacleStart,
obstacleStart + height > 100 ? 100 : obstacleStart + height
]
)
}
}

再将背景用游戏图片填充,我们就将整个游戏场景,绘制完成:



3 进行碰撞检测


由于飞行轨迹和障碍物数据都很简单,所以我们可以将碰撞逻辑简化为小鸟图片的正方形中,我们判断右上和右下角是否进入了自定义图形的范围内。


对于特定坐标下的碰撞范围,因为柱子固定每格50坐标值一个,宽度也是固定的,所以,可碰撞的横坐标范围就可以简化为 (x / 50 % 1) < 0.6


在特定范围内,依据Math.floor(x / 50)获取到对应的数据,即可判断出两个边角坐标是否和柱子区域有重叠了。在动画帧中判断,如果重叠了,就停止动画播放,游戏结束。


// centerCoord为散点坐标点
function judgeCollision(centerCoord) {
if (centerCoord[1] < 0 || centerCoord[1] > 100) {
return false;
}
let coordList = [
[centerCoord[0] + 15, centerCoord[1] + 1],
[centerCoord[0] + 15, centerCoord[1] - 1],
]

for (let i = 0; i < 2; i++) {
const coord = coordList[i];
const index = coord[0] / 50;
if (index % 1 < 0.6 && obstacleData[Math.floor(index) - 3]) {
if (obstacleData[Math.floor(index) - 3][1] > coord[1] || obstacleData[Math.floor(index) - 3][2] < coord[1]) {
return false;
}
}
}
return false
}

function initAnimation() {
// 动画设置
timer = setInterval(() => {
// 小鸟速度和仰角调整
vh = vh - a;
option.series[0].data[0][1] += vh;
option.series[0].data[0][0] += vw;
option.series[0].symbolRotate = option.series[0].symbolRotate ? option.series[0].symbolRotate - 5 : 0;

// 坐标系范围调整
option.xAxis.min += vw;
option.xAxis.max += vw;

// 碰撞判断
const result = judgeCollision(option.series[0].data[0])

if(result) { // 产生碰撞后结束动画
endAnimation();
}

myChart.setOption(option);
}, 25);
}

总结


echarts提供了强大的图形绘制自定义能力,要使用好这种能力,一定要理解好数据坐标点和像素坐标点之间的转换逻辑,这是将数据具象到画布上的重要一步。


运用好这个功能,再也不怕产品提出奇奇怪怪的图表需求。


作者:DevUI团队
链接:https://juejin.cn/post/7034290086111871007

收起阅读 »

最新·前端的工资分布情况 - 你拖后腿了吗?

前言要说我们工作最关心的东西肯定少不了这两个方向:我们前端开发的工资分布情况技术更新的风向今天我就和大家分享小生最近收集的一些数据。关于行业的平均薪资水平我们一定不要拿一些特例当成范例。最能反应行业的平均薪资的指标应该是正态分布的中间值。再说明一点:知乎和脉脉...
继续阅读 »



前言

要说我们工作最关心的东西肯定少不了这两个方向:

  1. 我们前端开发的工资分布情况
  2. 技术更新的风向

今天我就和大家分享小生最近收集的一些数据。

关于行业的平均薪资水平我们一定不要拿一些特例当成范例。最能反应行业的平均薪资的指标应该是正态分布的中间值

再说明一点:

知乎和脉脉上的薪资水平比整体偏高,不建议作为依据。

总体分布情况

我们先看一下每个工作年限对应的平均工资是多少(这里只收集了北上广深杭五个城市)

工作年限应届生1-3年3-5年5年+可信度
北京9.5K13.7K19.5K25.9K较高
上海8.8K12.9K17.6K22.5K较高
深圳9K12K15.9K21.8K较高
杭州8.9K11.7K15.8K20K存疑
广州7.9K10.1K13.6K17.8K不高

数据来源: http://www.jobui.com/salary/?cit…

说明:

  • 应届生的样本数相对来说少了很多,可信度不会很高。
  • 这里没有按学历区分,所以 985 高校的同学可能觉得偏低。

绝大多数人,3-5 年经验,薪资范围基本都在 15k - 20k。所以千万不要妄自菲薄。

北上广深杭平均工资

  • 这部分的数据来源于 - 职友集

全国

数据来源职友集

  • 全国 平均工资为: ¥12330(这个数据基本被一线城市平均了)
  • 分布最多的区间为:10K 到 15K

北京

数据来源职友集

  • 北京 平均工资为: ¥18770
  • 分布最多的区间为:20K 到 30K

上海

数据来源职友集

  • 上海 平均工资为: ¥16220
  • 分布最多的区间为:10K 到 15K

深圳

数据来源职友集

  • 深圳 平均工资为: ¥15090
  • 分布最多的区间为:10K 到 15K

广州

数据来源职友集

  • 广州 平均工资为: ¥11390(广州表示不服,竟然没有全国平均高?)
  • 分布最多的区间为:10K 到 15K

杭州

数据来源职友集

  • 杭州 平均工资为: ¥14350
  • 分布最多的区间为:10K 到 15K

后话

我也看了下看准网发布的数据(不分地区的平均工资达到了 ¥19800)和一些其他来源的数据。结合个人的经验,认为这份数据还是比较客观的。

这次你是否又拖后腿了呢?

作者:小生方勤
来源:https://juejin.cn/post/6844904082193268749 收起阅读 »