Google V8引擎浅析-面向对象

发表于 2年以前  | 总阅读数:300 次

继续上次分享的主旨,再来谈一谈V8引擎的一些细节优化的处理,对第一期感兴趣的同学可以看下:Google V8引擎浅析[1]

写在前面

JavaScript 中的对象扮演着举足轻重的角色,本次分享希望能从底层机制的角度,来探究下V8引擎在对象处理上,又做了哪些性能方面的优化,又能给我们日常工作上带来哪些比较有趣启迪或者值得遵守的建议呢?

对象属性优化

Fast properties in V8 · V8[2]

从语言的角度来看,JavaScript中的对象像一个字典,字符串作为键名,任意对象可以作为键值,可以通过键名读写键值。本次快慢属性的讨论范围,只限于单一对象的内部属性查找,不涉及到从原型链向上查找这一场景。

V8并没有完全采用字典的结构来存储数据,因为我们知道,非线性结构查找数据的效率是低于线性结构的(连续内存空间分配后的查找明显是优于非连续空间的),而是为了提升存储和查找的性能,采用了一套较为复杂的策略。

我们先以代码示例来看下:

function testV8() { // 随意定义一些key和value的组合,有整数的、小数、字符串等key

    this[1] = 'test-1'

    this["B"] = 'foo-B'

    this[50] = 'test-50'

    this[8] = 'test-8'

    this[3] = 'test-3'

    this[5] = 'test-5'

    this["4"] = 'test-4'

    this["A"] = 'foo-A'

    this["C"] = 'foo-C'

    this[4.5] = "foo-4.5" 

}

const testObj = new testV8();

for (let key in testObj) {

  console.log(`${key}:${testObj[key]}`);

}

控制台输出的key-value的结果:

多次打印后,发现输出的顺序是一致的,并没有随机性,但同样没有按照我们定义的顺序来输出,根据这一个现象,我们就要探究下,在V8引擎中,对象属性内部的设计思想。

在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存两种属性。

origin_img_v2_b7194174-62a0-4909-8bbc-a22f89d350bg.png

如果在数组属性(排序属性)和命名属性(常规属性)同时存在的情况下,优先数组属性排序,上面的例子中将"4"转换成了数字整型,而浮点数"4.5"转换成了字符串,V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。我们通过chrome调试工具snapshot来佐证下:

但为什么上面的快照中的对象却没有显示properties的属性呢?这就和V8的另外一个优化策略有关了:对象内属性In-object Properties)。

对象内属性In-object Properties

当采用两种线性结构存储后,在查询属性的时候,就会明显多出了一个步骤,要先去查询到Properties对应的对象(多了一次寻址的过程),再从Properties对象中查到对应的某个key的值,V8为了提升这个过程的效率,提出来了对象内属性的思路:当对象的属性数量小于10个的情况,直接将属性key存在对象内的属性上,如果需要查询某个key的值时,直接中对象内中获取对应key的值就可以了。

我们再来验证下:

function testV8(properties, elements) {
  //添加可索引属性

  for (let i = 0; i < elements; i++) {
    this[i] = `element${i}`;
  }

  //添加常规属性

  for (let i = 0; i < properties; i++) {
    const prop = `property${i}`;

    this[prop] = prop;
  }
}

const testobj = new testV8(15, 10);

for (let key in testobj) {
  console.log(`${key}:${testobj[key]}`);
}

快属性

保存在线性数据结构中的属性,通过索引就可以访问到对应的属性值,但也存在一个缺点,就是在删除的时候效率不高。

慢属性

属性过多的时候,V8会采用"慢属性"的处理,属性的对象内部会有独立的非线性数据结构(字典)

当属性数量不是特别多的情况下,Properties的索引是有序的(快属性),但当属性数量特别多的时候,就会变成无序的字典类型的存储(慢属性)。

const testobj = new testV8(10, 10);

%DebugPrint(testobj); 

const testobj1 = new testV8(150, 100);

%DebugPrint(testobj1); 

为什么还要用慢属性,而不用全部线性结构的快属性呢?

简单来讲:在属性的数量比较大的时候,快属性的访问的速度就不一定比慢属性的速度快了。

我们可以粗略的计算一次:假定一次哈希运算的成本,等于n次简单位运算的时间,一个对象有M个属性值,如果全部使用快属性,查询速度等于M次的简单位运算之和;如果使用慢属性,只需要一次哈希运算(n次位运算的成本) + 二维的线性比较(对比key值后取到对应key的value值),线性比较的成本远低于一次哈希运算的成本,这样估算的情况下,M > 2n 的情况下,慢属性的查询速度就会快于快属性的查询速度。

还有一种场景,当对一个对象频繁的插入属性或者删除属性的时候,如果一直用快属性,线性结构的查询效率是O(1),但插入或者删除时的效率就变成了O(n),这个时候V8就会降级处理成慢属性。

隐藏类(Hidden Class)

静态语言中,当创建类型后,就不能再次改变了,属性可以通过固定的偏移量来访问,但在js中却不是,对象的属性的类型、值等信息是可以随时改变的,也就是说运行的时候才能拿到最后的属性内存偏移量,V8为了提升对象的属性获取性能,设计了Hidden Class 隐藏类的概念,每一个对象都有对应的隐藏类,当每次对象的属性发生改变时,V8会动态更新对应的内存偏移量更新到隐藏类中。

  • 每个对象都拥有自己的隐藏类:上面例子中对应的map属性就是隐藏类对象。

  • 隐藏类中记录了对象中每个属性的标识信息(descriptors),它保存了属性key以及描述符数组的指针。描述符数组包含了有关命名属性的信息,例如名称本身以及值保存的位置,但只会存命名属性相关的,不会保存整数类的属性

  • 当对象创建一个新属性,或者一个老属性被删除时,V8会创建一个新的隐藏类并通过back_pointer指针指向老的隐藏类,新的隐藏类中只记录进行了变更的属性信息,随后对象指向隐藏类的指针会指向新的隐藏类。
const testobj1 = new testV8(2, 3);

const testobj2 = new testV8(2, 3);

testobj2.new = "new";

  • 对象创建一个新属性时,会检查该对象隐藏类的转换信息(transition information) 。如果转换信息包含了与当前属性更改相同的条件,则对象会将其隐藏类变更为转换信息中记录的类,而不会再创建一个新的隐藏类。
const testobj1 = new testV8(2, 3);

const testobj2 = new testV8(2, 3);

const testobj3 = new testV8(2, 3);

testobj2.new = "new"; // 

testobj3.new = "new"; // testobj2 3的隐藏类使用的是一个,不是新创建一个

在引擎的底层,V8 创建了一个将隐藏类连接在一起的转换树(transiton tree),相同顺序增加属性,会保证隐藏类的引用是同一个。

带孔(hole)的数组

当我们去查询一个数组里面的元素时,如果数组本身并不存在,按照原型链的原理,会逐一向上查询,这样就增加了“多余”的开销,在V8中增加了对数组是否全部充满(packed)的判断,如果数组是packed的情况下,再查不到对应的值,就不会再沿着数组的原型链查询了,而是在当前作用域中直接查询。

const a = [1, 2, 3];

delete a[1];

a.__proto__ = {1:2};

console.log(a[1]); //2

a最开始是完全填满的数组(Paked-Array), 但行2把第二位的元素删除掉了,V8同时增加了一个_hole来标记缺失的元素,表明a已经不再是充满的数组了,再当去查询的时候才会去按照原型链的原理去查询。这个策略对于数组的查询至关重要。

const a = new Array(10); // HOLEY_SMI_ELEMENTS

const b = [1,2,3,4] // PACKED_SMI_ELEMENTS

%DebugPrint(a);

%DebugPrint(b);

为什么数组也是对象类型的?

const c = [1, "hello", true, function () { // 数组内部也是用key-value的存储形式

  return 1;

}];

%DebugPrint(c);// PACKED_ELEMENTS

快慢数组

const a = [];

a[9999] = "9999";

如果V8要按照正常的逻辑处理声明的话,会新开10000个数组长度,这种是对空间相当浪费的,V8这个时候会把数组降级的慢数组,改用Object.defineProperty(object, key, descriptor)的Api来定义。

那究竟什么是快数组和慢数组呢?我们看下V8底层对于数组的定义:https://source.chromium.org/chromium/chromium/src/+/master:v8/src/objects/js-array.h

  • Fast模式的存储结构是 FixedArray [3]并且长度小于等于elements.length,可以通过 push 和 pop 扩容和缩容数组(弱语言类型,无法固定空间,只能通过length变化实现),实现内存空间的连续
  • slow模式的存储结构是一个以数字为键的 HashTable[4](哈希表),不用开辟连续的内存空间,节省内存

数组扩容

空数组预分配的大小为4

    // v8/src/objects/js-objects.h 551 
  static const uint32_t kMinAddedElementsCapacity = 16;


  // Computes the new capacity when expanding the elements of a JSObject.
  static uint32_t NewElementsCapacity(uint32_t old_capacity) {
    // (old_capacity + 50%) + kMinAddedElementsCapacity
    // 扩容公式:原有内存容量(1.5倍)+ 16
    return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity;
  }
template <typename TIndex>

// 尝试扩容元素空间
TNode<FixedArrayBase> CodeStubAssembler::TryGrowElementsCapacity(
    TNode<HeapObject> object, TNode<FixedArrayBase> elements, ElementsKind kind,
    TNode<TIndex> key, TNode<TIndex> capacity, Label* bailout) {
  static_assert(
      std::is_same<TIndex, Smi>::value || std::is_same<TIndex, IntPtrT>::value,
      "Only Smi or IntPtrT key and capacity nodes are allowed");
  Comment("TryGrowElementsCapacity");
  CSA_SLOW_DCHECK(this, IsFixedArrayWithKindOrEmpty(elements, kind));


  // If the gap growth is too big, fall back to the runtime.

  TNode<TIndex> max_gap = IntPtrOrSmiConstant<TIndex>(JSObject::kMaxGap);

  TNode<TIndex> max_capacity = IntPtrOrSmiAdd(capacity, max_gap);

  GotoIf(UintPtrOrSmiGreaterThanOrEqual(key, max_capacity), bailout);



  // Calculate the capacity of the new backing store. 计算出新的存储空间

  TNode<TIndex> new_capacity = CalculateNewElementsCapacity(

      IntPtrOrSmiAdd(key, IntPtrOrSmiConstant<TIndex>(1)));

  // 执行扩容

  return GrowElementsCapacity(object, elements, kind, kind, capacity,
                              new_capacity, bailout);
}

扩容后将数组拷贝到新的内存空间中

数组缩容

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/elements.cc;l=739

数组的pop方法会触发PopImpl方法,PopImpl又会调用RemoveElement,RemoveElement最后会调用到SetLengthImpl方法,判断是否要缩容,如果容量大于等于实际内容的length*2 + 16, 则进行缩容调整,否则就用上面提到的_hole标记来填充未初始化的位置,elements_to_trim是来计算要裁剪掉的大小,通过length + 1 和 old_length来判断是将空出来的空间全部裁掉还是留一半。

快慢转换

  • 快 > 慢
static const uint32_t kMaxGap = 1024;


static const uint32_t kPreferFastElementsSizeFactor = 3;


class NumberDictionaryShape : public NumberDictionaryBaseShape { 

  public: 

    static const int kPrefixSize = 1; 

    static const int kEntrySize = 3; 

};

  1. 如果快数组新的容量>= 3 * 扩容后的容量 * 2,意味着它比 HashTable 形式存储占用更大的内存,快数组会转换为慢数组;
  2. 如果快数组新增的索引与原来最大索引的差值大于等于 1024,中间全部是_hole标记,快数组会被转换会慢数组。
const a  = [1]

a[1025] = 3;

%DebugPrint(a); 

  • 慢 > 快

元素能存放在快数组中并且长度不在smi之间(64位-2^31到2^32-1),并且当前慢数组空间相比快数组节省值小于等于50%,则转变成为快数组。

const a  = [1]

a[1025] = 3;

for (let i = 300; i < 1025; i++) {

    a[i] = i;

}

%DebugPrint(a); 

  • 快数组就是以空间换时间的方式,申请了大块连续内存,提高了执行效率。
  • 慢数组以时间换空间,不必申请连续的空间,节省了内存,但需要付出效率变差的代价。

总结:

js的数组看似不同,其实只是V8 在底层实现上做了一层封装,使用两种数据结构实现数组,并且通过时间和空间2个纬度的取舍,优化了数组的性能。

内联缓存策略 (Inline Cache)

虽然隐藏类能够加速查找对象的速度,但是在 V8 查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。

function getXProps(o) {

    return o.x;

}

const o1 = {x:1, y:2, z:3};

const o2 = {x:3, y:100, m:10, n: -3};

for(let i = 0; i < 10000; i++) {

    getXProps(o1);

    getXProps(o2);

}

上段代码中getXProps的方法会被反复执行,也就是代表着:查找o对象的隐藏类,再通过隐藏类查找x属性的偏移量,最后再通过偏移量获取属性值的这个过程会被反复执行,V8对这种过程是否有优化呢?

答案就是内联缓存 (Inline Cache) ,简称为 ****IC,这个技术已经很古老了,最初的应用是在Smalltalk虚拟机上[5],原理简单讲就是在代码运行过程中,收集一些关键数据信息,将这部分信息缓存起来,再次执行的时候直接用这些信息,有效的节省了再次获取这些信息的消耗,从而提高了性能。

V8中利用IC的机制,为每个函数维护了一个反馈向量(FeedBack Vector),其实就是用了一个表结构来存储关键信息:

slot(插槽索引) type(插槽类型) state(状态) map(隐藏类地址) offset(属性偏移量)
0 Load(加载对象属性) mono(单态) xxxxxxxx 8
1 Store(属性赋值) poly(多态) xxxxxxxx 13
n ..... maga(超态) ...... ......
  • 单态:一个插槽中,只包含1个隐藏类
  • 多态:一个插槽中,包含了2-4个隐藏类
  • 超态:一个插槽中,包含了超过4个隐藏类

function setProps(o) {

    o.y = 4;

    return o.x;

}

setProps({x:1,z:5});

setProps({x:100,z:20});

执行过程分析:

  1. V8识别出来有两个调用点,o.y 和return o.x
  2. 在执行的时候,每个调用点会向反馈向量(FeedBack Vector)表中插入一条缓存数据
  3. 当再次调用setProps方法的时候,每次执行到对应点的时候,V8就直接先去对应的插槽中寻找对应属性的偏移量(offset),之后就直接可以从内存中获取对应的属性值就可以了,大大提升了V8的执行效率
  4. 上面的{x:1,z:5}和{x:100,z:20} 属性名和顺序都是一致的,所以隐藏类是同一个,属于单态,而最开始的例子中,隐藏类是2个,所以对应的是多态。多态的情况下,要在map中的多个隐藏类进校一一对比
let data = [1, 2, 3, 4];

let data1 = ['1', 2, '3', 4];

data.forEach((item) => console.log(item.toString());

data1.forEach((item) => console.log(item.toString());

// 哪个效率更高,why?

总结

  • 尽量保持单态,比如定义了一个公共方法loadX(obj),参数的形状尽量的保持相同的一种。
  • 尽量不要大量的对对象添加删除操作,避免命名属性退化成慢属性。
  • 尽量同类型的对象,对象的属性、顺序、个数要保持一致,避免创建过多的隐藏类。
  • 尽量不要一次初始化超长的数组,同时不赋值
  • 实际的项目中,不要过度考虑是否破坏了V8底层的优化机制,找出那些直接影响性能瓶颈的问题才至关重要

下期预告

V8垃圾回收器&内存管理

消息队列&异步编程

协程与进程

参考资料

[1]Google V8引擎浅析: https://juejin.cn/post/7018468848886579214

[2]Fast properties in V8 · V8: https://v8.dev/blog/fast-properties

[3]FixedArray : https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/fixed-array.h;l=101;bpv=0;bpt=1

[4]HashTable: https://source.chromium.org/chromium/chromium/src/+/main:third_party/swiftshader/third_party/llvm-10.0/llvm/include/llvm/DebugInfo/PDB/Native/HashTable.h;l=103;drc=1b51a630d5f980e6d1b22c90d1891ddc809313e1?q=%20HashTable&ss=chromium%2Fchromium%2Fsrc

[5]在Smalltalk虚拟机上: https://zh.wikipedia.org/zh-hans/%E5%86%85%E8%81%94%E7%BC%93%E5%AD%98

[6]JavaScript 引擎基础:Shapes 和 Inline Caches: https://zhuanlan.zhihu.com/p/38202123

[7]V8 Hidden class - LINE ENGINEERING: https://engineering.linecorp.com/en/blog/v8-hidden-class/

[8]Fast properties in V8 · V8: https://v8.dev/blog/fast-properties

[9]Explaining JavaScript VMs in JavaScript -: https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/MRDnh959wDD5LC3k6IMo9w

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237273次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8108次阅读
 目录