Gone With the Wind

一个使用Launch-Screen但是无法显示图片的坑

使用StoreBoard来作为Launch Screen的时候,通常做法是在Store Board里面加一个UIImageView,然后里面设置相应的图片就OK了,例如下面这样:

我把app-starpup这张图片放到了Assets.xcassets里面,然后在storyboard里面引用了一下,一切都很简单,看起来也毫无问题,但是编译完到启动的时候,发现无论如何这样图片都显示不出来,我以为是同时设置了Launch Image的问题,然后调试了差不多一个小时,最后终于意识到了问题所在:Assets.xcassets里面的文件在启动的时候还没有被加载,把图片放到某个文件夹里面,然后再引用文件夹里面的图片,问题就解决了。

多次setRootViewController导致view无限叠加的问题

背景

最近在开发一个iOS项目,有这么几个页面:登录页、引导页、主页。因为这几个页面之间几乎没有关联,所以就做成了3个不同的viewController,然后通过直接[UIWindow setRootViewController:]的方式来进行页面切换,其中,引导页和主页是单例,登陆页因为某些原因每次使用都会实例化。

出现的问题

正常使用流程是:引导页-> 登录页 -> 主页,流程很顺,但是在退出后就出问题了,在主页退出后,重新展示登录页,然后在登陆页登陆完了之后,调用[UIWindow setRootViewController:]设置主页单例,打了断点发现逻辑已经调用,但是发现无论怎么设置,都没用,屏幕上依然是登录页,主页并没有显示出来,非常疑惑。

问题原因

打开xcode的调试功能:View UI Hierarchy,就能看到类似如下的场景了。(保密需要没有用自己的图)

最下面是引导页,然后中间是登录页,然后最上面是主页,然后因为退出之后又显示了登陆页,而登录页不是单例,所以在主页上又覆盖了一层登录页,而因为主页是单例,所以在第二次setRootViewController之后,并没有生效。而看了一下setRootViewController的作用,主要逻辑就是把要添加的viewController的view添加到window上,UIWindow本身就是一个view。

危害

这个问题其实还是挺有风险的,如果我的主页不是单例,那么我甚至都发现不了这个问题,只会一层一层往上叠加,就会造成内存泄露了。

解决方法

知道了原因,解决起来就轻松了,两种解法:

  1. 不使用setRootViewController的方式,而且采用pushViewController或者presentViewController的之类的方式去显示页面。
  2. 修改setRootViewController,在每次调用之前先删掉UIWindow上所有的view,然后在添加。

其实正常来说,还是推荐使用第一种方式来切换页面,而我因为改动比较大,而且时间比较急,所以采用了第二种方式,代码如下:

1
- (void)setRootViewController:(UIViewController *)rootViewController
{
    //remove old rootViewController's sub views
    for (UIView* subView in self.rootViewController.view.subviews)
    {
        [subView removeFromSuperview];
    }
    
    //remove old rootViewController's view
    [self.rootViewController.view removeFromSuperview];
    
    //remove empty UILayoutContainerView(s) remaining on root window
    for (UIView *subView in self.subviews)
    {
        if (subView.subviews.count == 0)
        {
            [subView removeFromSuperview];
        }
    }
    
    //set new rootViewController
    [self addSubview:rootViewController.view];
    [super setRootViewController:rootViewController];
}

伟大的单元测试

什么是单元测试?

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

举个例子

待测试类

1
"use strict";

class Car {

  constructor() {
    this.status = "stop";
  }

  // 开车
  start() {
    this.status = "start";
    this._lockDoor();
  }

  // 停车
  stop() {
    this.status = "stop";
    this._unlockDoor();
  }

  // 锁车门
  _lockDoor() {
    console.log( "door locked" );
    this._lockFrontDoor();
    this._lockBackDoor();
  }

  // 解锁车门
  _unlockDoor() {
    console.log( "door unlocked" );
    this._unlockFrontDoor();
    this._unlockBackDoor();
  }

  _lockFrontDoor() {
    console.log( "front door locked" );
  }

  _lockBackDoor() {
    console.log( "back door locked" );
  }

  _unlockFrontDoor() {
    console.log( "front door unlocked" );
  }

  _unlockBackDoor() {
    console.log( "back door unlocked" );
  }

};

module.exports = Car;

以逻辑单元为最小单位的测试

1
"use strict";

const assert = require( "assert" );

const Car = require( "./sample" );

describe( "test Car", function() {

  it( "start car", function () {
    let car = new Car();

    assert.equal( car.status, "stopped" );

    let locked   = false;
    // MOCK
    car._lockDoor = function () {
      locked     = true;
    }

    car.start();

    assert.equal( car.status, "running" );
    assert.equal( locked, true );

  } );

  it( "lock door", function () {
    let car = new Car();

    let frontDoorLocked = false;
    let backDoorLocked  = false;
    car._lockFrontDoor  = function () {
      frontDoorLocked     = true;
    };

    car._lockBackDoor   = function () {
      backDoorLocked      = true;
    };

    car._lockDoor();

    assert( frontDoorLocked, true );
    assert( backDoorLocked, true );

  } );

} );

以业务单元为最小单位的测试

1
"use strict";

const assert = require( "assert" );

const Car = require( "./sample" );

describe( "test Car", function() {

  it( "start car", function () {
    let car = new Car();

    assert.equal( car.status, "stop" );

    let _lockFrontDoor  = car._lockFrontDoor;
    let _lockBackDoor   = car._lockBackDoor;

    let frontDoorLocked = false;
    let backDoorLocked  = false;

    car._lockFrontDoor  = function () {
      frontDoorLocked     = true;
      _lockBackDoor();
    };

    car._lockBackDoor   = function () {
      backDoorLocked      = true;
      _lockFrontDoor();
    };

    car.start();

    assert.equal( car.status, "running" );
    assert.equal( frontDoorLocked, true );
    assert.equal( backDoorLocked, true );

  } );

} );

一些单元测试原则

  • 单元测试必须由最熟悉代码的人(程序的作者)来写。
  • 单元测试应该在最低的功能/参数上验证程序的正确性。
  • 单元测试过后,机器状态保持不变。
  • 单元测试要快(一个测试运行时间是几秒钟,而不是几分钟)。
  • 单元测试应该产生可重复、一致的结果。
  • 独立性,单元测试的运行/通过/失败不依赖于别的测试,可以人为构造数据,以保持单元测试的独立性。
  • 单元测试应该覆盖所有代码路径,包括错误处理路径,为了保证单元测试的代码覆盖率,单元测试必须测试公开的和私有的函数/方法。
  • 单元测试应该集成到自动测试的框架中。
  • 单元测试必须和产品代码一起保存和维护。

单元测试的好处

  • 保证现有代码的功能都是正常的。
  • 若不清楚代码而修改了一个逻辑,运行一下单元测试就知道影响到了什么地方。
  • 反向推动代码做到一个单元的功能最小化。
  • 写单元测试的过程中可以重新梳理自己的代码,找出bug。
  • 重构系统而不改变功能时,对照单元测试来写自己的功能就可以,即使之前的代码不是自己写的。

单元测试不好的地方

  • 时间成本,直接导致人力成本上升。不过也有可能前提代码质量上的提升可以减少后期很多修BUG的时间。
  • 有些外部依赖较多并且逻辑相对简单的时候,初始化外部依赖或者mock的精力复杂度可能已经超过了业务代码本身。
  • 设计影响。有时候,为了方便测试,甚至已经影响到你的业务代码的逻辑。
  • 需求快速变化期间。有时候需求变化非常快速,单元测试会降低业务变化的敏捷度。

MOCK

MOCK 简单来说就是纸老虎。

业务代码

1
"use strict";

const DB = require( 'database' );

class Mock {

  getList() {
    const result = DB.query( "select * from table" );
    for( let i = 0; i < result.length; i ++ ) {
      // do somting
    };

    return result;
  }

};

module.exports = Mock;

测试代码

1

const Mock = require( './mock' );

const DB   = require( 'db' );

describe( "test Mock", function () {

  it( "get list", function () {
    DB.query = function ( sql ) {
      return [ 1, 2, 3 ];
    };

    const car    = new Car();
    const result = car.getList();

    assert.equal( result, [ 1, 2, 3 ] );
  } );

} );

为什么我的代码需要mock?

优点

  • 可以不用去关注一些底层支撑系统,例如DB,如果不MOCK,那么还需要初始化一个DB,使用前使用后都要去清理环境,给自己擦屁股。
  • 可以更加关注于自己业务代码的业务逻辑,例如去初始化一个DB,需要很多配置信息。
  • 不会因为数据库的故障导致自己的单元测试失败。
  • 隔绝别的模块,别的模块即使只申明了接口,也可以正常跑完自己的单元测试。

缺点

  • 如果mock对象很复杂,属性很多,方法也很多,那就需要自己去录制相当多的工作去实现这个mock.
  • 有些语言难以mock。一些强类型语言和编译型语言对于mock还是比较难的。
  • mock会掩盖某些环境。比如各种输出的时间格式会根据用户的环境而定,不同的地域也会有不同的格式,这个时候用mock返回自己所在地区或者自己所在环境的格式,看似单元测试全部通过,实则埋下了隐患。
  • 在不清楚被mock对象的所有边界行为时,可能会导致程序没有处理一些边界问题而超出预期。

TDD

TDD(Test Drive Development),单元测试带来的一个非常好的开发方式。TDD思想让你在写代码之前,就先写单元测试,这个时候可以让你把整个代码从整体层面上运行起来,然后再去实现每个方法具体的功能。

优点

  • 在设计阶段,划分方法,划分类的时候,无需关注某个类或者方法的具体的实现,只要达到功能即可。
  • 在实现某个类或者某个方法的时候,无需关注这么设计是否合理,直接按照方法名实现方法即可。

BDD

BDD指的是Behavior Drive Development,也就是行为驱动开发。这里的B并非指的是Business,实际上BDD可以看作是对TDD的一种补充,当然你也可以把它看作TDD的一个分支。因为在TDD中,我们并不能完全保证根据设计所编写的测试就是用户所期望的功能。BDD将这一部分简单和自然化,用自然语言来描述,让开发、测试、BA以及客户都能在这个基础上达成一致。因为测试优先的概念并不是每个人都能接受的,可能有人觉得系统太复杂而难以测试,有人认为不存在的东西无法测试。所以,我们在这里试图转换一种观念,那便是考虑它的行为,也就是说它应该如何运行,然后抽象出能达成共识的规范。如果你用过JBehave之类的BDD框架,你将会更好的理解其中具体的流程。这里我推荐一篇具体阐述的文章。

DDD

Deadline Drive Development.

软件开发进度表

时间 汇报进度 真实进度
第一天 20% 5%
第二天 50% 10%
第三天 70% 10%
第四天 80% 10%
第五天 90% 15%
第六天 100% 100%

iOS中实现高精度定时器的方法

背景

最近自己业余时间在开发一个录音软件,要画一个声音的音频图,大概类似下面的样子:

在一秒钟内要取大概20次当前声音的大小,用以绘制成一幅图,那么首先,想当然的当然是用NSTimer了,写了代码如下:

1
// 当然这里把每秒钟需要的次数设置成一个变量会更好。
[NSTimer scheduledTimerWithTimeInterval:1 / 20
                                repeats:YES
                                  block:^(NSTimer * _Nonnull timer) {
                                    // do somting.
                                  }];

block里面去取了一下当前音量,绘制成了一个图,代码运行起来没问题,音量正确得取到了,并且也绘制在了图上面,乍一看发现没什么问题,等了十几秒再回头看,发现貌似时间过得很慢,明明感觉已经起码过了十秒,但是录音图上停留在6秒7秒的样子,很疑惑,在block中,加了一个Log,就看出缘由了。

设置的明明是0.05秒执行一次,但是从图中来看,基本都是隔了0.1秒才执行一次,很少有0.5秒才执行一次,NSTimerrunloop运行的时候执行,runloop中有其他任务时势必会影响到NSTimer,但是没想到影响会那么大,查阅了官方文档后发现,iOS设备中,NSTimer的精度在0.1秒左右,看来是需要另寻他法了。

平均误差:18.11

while循环

具体思路就是新开一个线程,然后跑一个while循环去检测,代码如下:

1
dispatch_queue_t timerQueue = dispatch_queue_create("timer", DISPATCH_QUEUE_SERIAL);
    
    dispatch_async(timerQueue, ^{
        while (YES) {
            usleep( 50000 );
            NSLog(@"timer");
        };
    });

平均误差:65.33毫秒

貌似比NSTimer还不靠谱,经过测试,发现精度基本上在200MS左右。

CADisplayLink的调用是根据屏幕刷新率来的,通常iOS设备的刷新率在60HZ,那么一秒钟就能够被调60次。

1
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(testDisplayLink)];
link.paused = NO;
[link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

平均误差:0.002(在每秒60次调用的情况下)

相当精确,在固定刷新率下,精度基本在0.001毫秒的样子,但是测试发现CADisplayLink有以下问题:

  1. 时间间隔只能是 1 / 60,没法自定义,可以设置 1 / 60的倍数。
  2. 首先精度在 1 / 60秒的样子,无法再精确了,在此基础上,调用间隔非常精确。
  3. 全局只能有一个CADisplayLink实例在运行。
  4. 屏幕在熄灭状态下无法调用此方法。

dispatch_after

通过递归得调用这个函数来实现

1
dispatch_after(
                   dispatch_time(
                                 DISPATCH_TIME_NOW,
                                 (int)(0.05 * (double)NSEC_PER_SEC)
                                 ),
                   dispatch_get_main_queue(), ^{
                       NSLog(@"dispatch after");
                       [self after];
                   });

平均误差:12.17毫秒。

目前看来是误差最小的了,如果不算CADisplayLink的话。

官方提供的高精度解决方案

官网地址:https://developer.apple.com/library/content/technotes/tn2169/_index.html

平均误差:0.34毫秒

大概测试了一下,间隔在1毫秒的时候,误差在0.7毫秒,间隔在5毫秒的时候误差0.36,间隔在2毫秒的时候误差在0.7毫秒,间隔在4毫秒的时候误差为0.3毫秒,间隔在3毫秒的时候误差在0.49

间隔 误差
5ms 0.36ms
4ms 0.3ms
3ms 0.49ms
2ms 0.7ms
1ms 0.7ms

总体来说,这种方式的精度在5毫秒左右,5毫秒已经能满足绝大多数需求了。

获取最小有效像素区域

背景

最近在做一个需求是对页面某一块区域截图,本身没什么,但是发现需要截图的区域非常大,然后里面的内容通常只有一小点,这样我把整张图片拿出去就会发现只有中间一点点有东西,其他地方都是空白的,用户体验就会比较差了,记得PhotoShop里面有一个功能是能截取整张图片的最小有效像素区域,本来以为canvas自带有这种功能,没想到还是要自己实现的,虽然功能很简单,但是网上也没找着相应类库,可能是我搜索的姿势不对,或者这个需求实在太简单了,没人封装成类库了,虽然完全没做过图像处理,也开始找一些资料。

文字描述比较抽象,放上一张图片。

原始图片

处理后图片

canvas

网页内容是用Html2Canvas把DOM截图成了canvas,原理不是很清楚,大概是读取DOM样式然后在canvas里面重新画一遍,这不是重点,这个时候我们已经拿到画成了的canvas了,改怎么处理呢?

1
var canvas  = document.getElementById( 'canvas' );
var ctx     = canvas.getContext( '2d' );
var imgData = ctx.getImageData( 0, 0, canvas.width, canvas.height );

通过getImageData方法就能够拿到canvas的原始像素点信息了,四个参数描述了一个矩形,返回的像素点信息就是这个矩形内的信息。

返回的是一个Uint8ClampedArrayUint8ClampedArrayJavaScript中的类型数组,具体的可以参看这里,简单来看就是这个数组中的元素都是unsigned int并且都是8位的,所以取值范围是0~255,非常适合用来存放ImageData,而且类型数组因为类型都是确定的,使用起来比普通数组速度上会快很多。

ImageData

拿到了原始的像素点数据,来看看数据长什么样子,首先看数组长度,假设是一个100X100的图片,那么理论上像素点的数量是100100,也就是10000个,拿到ImageData的数组看长度其实有40000,简单猜测一下大概是10000 rgba,看具体的数组内容很容易就能够验证我们的猜想。

一个典型的ImageData大概长这个样子,这是一个从左往右,从上到下按顺序排列的一个数组:

1
imageData = [ 
              0,   0,   0,   0,
              255, 255, 255, 1,
              122, 122, 122, 0.5,
              ....
            ];

如何获取最小像素区域?

要实现这个功能的本质,其实就是如何寻找最上、最右、最下以及最左四个点(P1,P2,P3,P4),有了这四个点,我们就能画出一个最小矩形,把这个图像框在里面。

一维组数映射到二维空间

根据数组下标计算当前点在二维空间的坐标,方法其实很简单。

看下面这张图,可以认为这是一张 5X4 的一张图片,那么总共就有20个像素点,每个格子里的0-19数字就意味着这是在一维数组里面的下标N,由此可得

X = N % width;
Y = Math.floor( N / width );

pixel

全数组扫描

首先能想到的最简单的办法,就是循环整个数组,当alpha值大于0的时候,即可判断这个点是一个有效像素点,然后整个数组循环完成之后,即可知道得到P1,P2,P3,P4四个点。

以下是实现代码:

1
function getEffectiveRect( imageData, width, height ) {
  // 上点, Y值最小
  var p1 = [ 0, height ];
  // 右点, X值最大
  var p2 = [ 0, 0 ];
  // 下点, Y值最大
  var p3 = [ 0, 0 ];
  // 左点, X值最小
  var p4 = [ width, 0 ];

  var data    = imageData.data;
  var number  = 0;
  console.time( 'image1' );
  for ( var i = 0; i < data.length; i ) {
    number++;
    var r = data[ i++ ];
    var g = data[ i++ ];
    var b = data[ i++ ];
    var a = data[ i++ ];
    if ( a === 0 ) {
      continue;
    }
    var x = number % width;
    var y = ( number - x ) / width;
    if ( p1[ 1 ] > y ) {
      p1[ 0 ] = x;
      p1[ 1 ] = y;
    }

    if ( p2[ 0 ] < x ) {
      p2[ 0 ] = x;
      p2[ 1 ] = y;
    }

    if ( p3[ 1 ] < y ) {
      p3[ 0 ] = x;
      p3[ 1 ] = y;
    }

    if ( p4[ 0 ] > x ) {
      p4[ 0 ] = x;
      p4[ 1 ] = y;
    }
  }

  return { p1, p2, p3, p4 };
}

从矩形的四个角分别去寻找

具体的思路如下图:

from-rect

理论上只要有效像素区域越大,那么需要计算的量就越少,速度也就越快,以下是我的实现代码:

1
function getEffectiveRect2( imageData, width, height ) {
  var data = imageData.data;

  console.time( 'image2' );
  var p1 = [];
  var p2 = [];
  var p3 = [];
  var p4 = [];

  // 寻找P1
  var len = 0
  for ( var i = 0; i < data.length / 4; i ++ ) {
    len   += 7;
    var a = data[ len ];
    if ( a !== 0 ) {
      var x = i % width;
      var y = ( i - x ) / width;
      p1    = [ x, y ];
      break;
    }
  };

  //寻找P3
  var len = 0;
  for ( var i = data.length / 4 - 1; i >= 0; i -- ) {
    len  += 7;
    var a = data[ len ];
    if ( a !== 0 ) {
      var x = i % width;
      var y = ( i - x ) / width;
      p3    = [ x, y ];
      break;
    }
  }

  // 寻找P2
  for ( var i   = width - 1; i >= 0; i -- ) {
    if ( p2.length ) {
      break;
    }
    var a = p1[ 1 ];
    var b = p3[ 1 ];
    var len = i + a * width;
    for ( var j = a; j < b; j ++ ) {
      len += width;
      // var point = i + j * width;
      var point = len;
      var a = data[ point * 4 + 3 ];
      if ( a !== 0 ) {
        var x = point % width;
        var y = ( point - x ) / height;
        p2    = [ x, y ];
        break;
      }
    }
  };

  // 寻找P4
  for ( var i = 0; i < width; i ++ ) {
    if ( p4.length ) {
      break;
    }
    for ( var j = p1[ 1 ]; j < p3[ 1 ]; j ++ ) {
      var point = i + j * width;
      var a = data[ point * 4 + 3 ];
      if ( a !== 0 ) {
        var x = point % width;
        var y = ( point - x ) / height;
        p4    = [ x, y ];
        break;
      }
    }
  }

  return { p1, p2, p3, p4 };
}

将两种方法分别去计算最开头的图片,两种方式的计算时间分别是:

1
image1: 10.83ms
image2: 15.32ms

结论

方法2引入的复杂的逻辑反而降低了计算时间,逻辑最简单的全数组扫描比第二种方式快了30%,因为涉及到的东西比较多,目前还不清楚到底是什么原因导致第二种方式速度如此慢,测试了一下,只有当一整张图片几乎全部有像素点的时候,两种方法的速度才差不多。后续我会继续寻找原因,猜测可能是方法2的计算量比方法1多,而方法1更多的是比较和赋值。

计算机眼中的数字

0.1 + 0.2 = ?

即使是没上过学的人,也知道答案是0.3,但是,在看似无所不能,计算速度远超人类的计算机来说,答案就不是0.3了。看看chrome下的结果:

1
0.1 + 0.2

0.30000000000000004

再看看python:

1
Python 2.7.10 (default, Jul 30 2016, 18:31:42) 
[GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.34)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 0.1 + 0.2
0.30000000000000004
>>>

再试试C语言:

1
#include "stdio.h"
 
  int main(){

  double a = 0.1 + 0.2;
  printf( "%.17lf", a );

}
1
0.30000000000000004

qustion

产生原因

对于数字的存储绝大多数语言遵循的都是IEEE 754规范,简单来说就是一些革命先烈告诉你当你在二进制计算机中想要一个浮点数的时候应该如何去表达。

首先,内存是有限的,所以一个数字,当它声明好了之后所占的位数是固定的,比如在C语言中,double类型所占的字节数是8,那么总共就是64位(可能某些奇葩编译器会有些差异),革命先烈们是怎么规定的呢?

double

有一个通用的计算公式:

float-cal

  1. (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  2. M表示有效数字,大于等于1,小于2。
  3. 2^E表示指数位。

举例来说,十进制的5.0,写成二进制是101.0,相当于1.01×2^2。那么,按照上面V的格式,可以得出s=0,M=1.01,E=2。
十进制的-5.0,写成二进制是-101.0,相当于-1.01×2^2。那么,s=1,M=1.01,E=2。

有几点需要注意:

  • 因为有效数字M的第一位必然是1,所以存储的时候就把这一位舍去了,可以空出一位用来存储,在计算的时候加回来就行了。

  • 指数E没有符号位,无法表示负数,所以在计算的时候减去一个半数,比如:double是64位,11位用来存储指数,假设你要保存指数10,那么就必须存储1023 + 10,也就是10000001001。ps: 以上讨论的都是64位浮点数,如果是float,也就是32位的浮点数,指数位数是8,保存指数10就是:127 + 10

对于指数E,还有一下三条更细的规则:

1
(1)E不全为0或不全为1。这时,指数E的计算值减去127(或1023),得到真实值,再将有效数字M前加上第一位的1。
(2)E全为0。这时,浮点数的指数E等于1-127(或者1-1023),有效数字M不再加上第一位的1,而是还原为0.xxxxxx的小数。这样做是为了表示±0,以及接近于0的很小的数字。
(3)E全为1。这时,如果有效数字M全为0,表示±无穷大(正负取决于符号位s);如果有效数字M不全为0,表示这个数不是一个数(NaN)。

好了,说完了背景,那么就可以来看看到底是什么原因了。

整数的二进制和十进制之间的相互转换,相信大家都会算,小数部分,很多人可能已经忘记如何计算了。

总体来说就是 乘二取整,比如0.1

1
0.1 * 2
0.2      >> 0
0.2 * 2
0.4      >> 0
0.4 * 2
0.8      >> 0
0.8 * 2
1.6      >> 1
0.6 * 2
1.2      >> 1

0.2 * 2
0.4      >> 0
0.4 * 2
0.8      >> 0
0.8 * 2
1.6      >> 1
......

0.1 变成了 0.0001 1001 1001 1001 .... 1001无限循环
0.2 变成了 0.0011 0011 0011 0011 .... 0011无限循环

对于计算机来说,0.1和0.2的二进制就变成了一个无尽数,其实大部分小数转换成二进制都是无穷数。于是,二进制的0.1和0.2就变成了:

竖线后面代表存储位数之外

1
0.0001 1001 1001 ...... 1001 | 1001   
0.0011 0011 0011 ...... 0011 | 0011

二进制相加之后变成了:

1
...... 1100 | 1100

由于存储位数限制,必须舍去竖线后面的有效数字,按照舍0进1的方式,就变成了 .... 1101,所以整个二进制数字就变成了:

1
0.0100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101

转换成十进制就是:

1
0.30000000000000004440892098500626

就变成了我们最初所看到的结果。

至此,我们开头的问题已经有了答案,那么下面有几个引申问题:

  • JS中类型存储位是52位,那么可以准确表达最大的数字就是:Math.pow(2, 53), 也就是:9007199254740992,那么以下几个表达式的值是什么呢?

9007199254740992 + 1
9007199254740992 + 2
9007199254740992 + 3
9007199254740992 + 4

  • 0.1, 0.2, 0.3, 0.4 转换成二进制小数都是无穷的,那么以下表达式的结果如何呢?

0.1 + 0.2 === 0.3
0.1 + 0.3 === 0.4
0.2 + 0.4 === 0.6
0.2 + 0.3 === 0.5