子子翔的专栏

挨踢民工生涯

Ionic中不合理的view层级导致afterEnter没有被调用

在公司的ionic项目中我们定义了如下状态:


$stateProvider
  .state('A', {
    abstract: true,
    views: {
      root: {
        template: '<ion-nav-view id="ViewA"></ion-nav-view>'
      }
    }
  })
  .state('A.B', {
    url: '/A/B',
    templateUrl: 'A/B.tpl.html',
    controller: 'ABCtrl'
  })
  .state('A.C', {
    abstract: true,
    url: '/A/C'
  })
  .state('A.C.D', {
    url: '/D',
    views: {
      'root@': {
        templateUrl: 'A/C/D.tpl.html',
        controller: 'ACDCtrl'
      }
    }
  })
  .state('E', {
    url: '/E',
    views: {
      root: {
        templateUrl: 'E.tpl.html'
      }
    }
  })


其中views里面的root是在index.html里定义的ion-nav-view:


<html>
  ...
  <body ng-app="starter">
    ...
    <ion-nav-view name="root"></ion-nav-view>
  </body>
</html>


并且ABCtrl和ACDCtrl的代码中都注册监听了afterEnter事件。

按理说从状态A.B跳转到状态A.C.D时,ACDCtrl里的afterEnter会被执行,可实际运行的时候却没有。但是从E跳转到A.C.D则没有问题,ACDCtrl里的afterEnter会如期被调用。从E跳到A.B也没有问题,ABCtrl里的afterEnter也会执行。

公司项目的ionic lib版本是1.3.1:


$ ionic lib
Local Ionic version: 1.3.1 (/Users/zhixiangzhu/my-ionic-project/www/lib/ionic/version.json)
Latest Ionic version: 1.3.3 (released 2017-02-24)
 * Local version is out of date

本文末尾附上了我自己写的一个ionic小项目专用于重现这个问题。该项目的ionic lib版本是1.3.3:


$ ionic lib
Local Ionic version: 1.3.3 (/Users/zhixiangzhu/ionic-afterEnter-test/www/lib/ionic/version.json)
Latest Ionic version: 1.3.3 (released 2017-02-24)
 * Local version up to date

于是我钻进了ionic的代码里研究了一番。afterEnter是在ionicViewSwitcher的transitionComplete函数中,也就是在状态跳转完成时触发的:

function transitionComplete() {
  ...
  // the most recent transition added has completed and all the active
  // transition promises should be added to the services array of promises
  if (transitionId === transitionCounter) {
    ...
    // emit that the views have finished transitioning
    // each parent nav-view will update which views are active and cached
    switcher.emit('after', enteringData, leavingData);  // ionic在这里触发afterEnter          
    ...
  }
  ...
}

可以看到afterEnter触发的条件是transitionId === transitionCounter。ACDCtrl的afterEnter没有被调用,正是因为这个条件没有被满足。

于是需要理解transitionId和transitionCounter分别是什么。两者的定义在如下代码中:

IonicModule.factory('$ionicViewSwitcher', [
...,
function(...) {
  ...
  var transitionCounter = 0;
  ...

  var ionicViewSwitcher = {

    create: function(navViewCtrl, viewLocals, enteringView, leavingView, renderStart, renderEnd) {
      // get a reference to an entering/leaving element if they exist
      // loop through to see if the view is already in the navViewElement
      var enteringEle, leavingEle;
      var transitionId = ++transitionCounter;

可以看到transitionCounter是一个全局变量。状态跳转时每创建一次ionViewSwitcher,transitionCounter计数就会加1。上面代码里的create函数是从ionNavView的$stateChangeSuccess响应函数一路调用进来的。


IonicModule
.directive('ionNavView', [
  ...,
function(...) {
  // IONIC's fork of Angular UI Router, v0.2.10
  // the navView handles registering views in the history and how to transition between them
  return {
    ...
    // listen for $stateChangeSuccess
    $scope.$on('$stateChangeSuccess', function() {
      updateView(false);
    });
    ...
    function updateView(firstTime) {
      // get the current local according to the $state
      var viewLocals = $state.$current && $state.$current.locals[viewData.name];

      // do not update THIS nav-view if its is not the container for the given state
      // if the viewLocals are the same as THIS latestLocals, then nothing to do
      if (!viewLocals || (!firstTime && viewLocals === latestLocals)) return;

      // update the latestLocals
      latestLocals = viewLocals;
      viewData.state = viewLocals.$$state;

      // register, update and transition to the new view
      navViewCtrl.register(viewLocals);  // ionicViewSwitcher的create函数是从这里一路调用进去的
    }
    ...

而$stateChangeSuccess事件是在状态跳转完成时在$rootScope上广播触发的:


var transition = $state.transition = resolved.then(function () {
  ...
  $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams);
  ...

经过一番研究,我发现故事正是可以从$stateChangeSuccess事件开始讲起。


我们先以状态E跳转到状态A.B这个没有出现问题的流程为例。

在状态跳转成功,也就是$stateChangeSuccess在$rootScope上广播触发的时候,其实跳转目标状态(A.C.D)和目标状态的祖先状态(A.C和A)对应的view还没有创建好,或是还处于非活跃状态。此时以$rootScope为根结点的scope树可以简化如下:


ion-nav-view name="root"
 |
 |-------- ion-view state="E"

$broadcast的算法是深度遍历,所以首先被遍历到的是位于根部的名为root的ion-nav-view。当$stateChangeSuccess在ion-nav-view的$scope上触发时,ionic会检查当前的ion-nav-view是否跳转目标状态或其祖先状态的其中任意一个view所在的容器。如果不是,那么ionic就会跳过这个ion-nav-view,遍历下一个。见上文updateView函数中的注释:

// do not update THIS nav-view if its is not the container for the given state


但是在E -> A.B这个例子中,ion-nav-view name="root"是状态A的view所在的容器。因此ionic会在当前的ion-nav-view中创建或唤醒相应状态(A)的view,并将transitionCounter计数器加1,赋值给相应view的transitionId。因为在状态A的定义中,A的view本身也含有一个ion-nav-view,所以现在scope树变成了这样(假设状态跳转之前transitionCounter为0):


ion-nav-view name="root"
  |
  |-------- ion-view state="E"
  |
  |-------- ion-nav-view id="ViewA"   transitionId = 1 

当新的ion-nav-view被创建的时候,它对自身也会执行一次updateView的流程,判断自己是否为目标状态或目标祖先状态的任意一个view所在的容器。在这里ion-nav-view id="ViewA"是A.B的view所在的容器,因此它会在自己的view中创建A.B的view,并再次增加transitionCounter计数,赋值给A.B的view的transitionId。此时的scope树如下所示:


ion-nav-view name="root"
  |
  |-------- ion-view state="E"
  |
  |-------- ion-nav-view id="ViewA"   transitionId = 1 
                  |
                  |-------- ion-view view-title="A.B"    transitionId = 2

由于A.B的view中没有ion-nav-view(详见文章末尾附件中的代码),且scope树中已没有未遍历的ion-nav-view,所以$stateChangeSuccess的广播到此结束。此时transitionCounter的值为2,而transitionId为2的正是A.B的view,于是在transitionComplete的时候afterEnter在ABCtrl上被触发。

上面的过程可以小结如下:在状态跳转成功的时候,ionic在$rootScope上广播$stateChangeSuccess事件,从scope树的根节点开始按深度遍历所有的ion-nav-view。如果当前正在遍历的ion-nav-view是目标状态或其祖先状态的view所在的容器,那么就会在其中创建或唤醒相应状态的view,增加transitionCounter计数并赋值view的transitionId。在$stateChangeSuccess广播完成之后,ionic会在transitionId最大(即等于transitionCounter)的view上,也就是最后创建或唤醒的view上触发afterEnter。

那么从状态A.B跳转到状态A.C.D时,为什么没有在ACDCtrl上触发afterEnter呢?让我们跟踪一下这个过程。

在A.B -> A.C.D的$stateChangeSuccess广播之前,scope树是这样的:


ion-nav-view name="root"
  |
  |-------- ion-nav-view id="ViewA" 
                  |
                  |-------- ion-view view-title="A.B" 

和之前一样,首先遍历到的是ion-nav-view name="root"。这里要注意,在A.C.D及其祖先状态中,以root为容器的既有状态A的view,又有状态A.C.D的view(见A.C.D定义的views)。在决定在ion-nav-view中创建或唤醒哪个状态的view这个问题上,ionic会优先考虑子状态的view。所以在ion-nav-view name="root"中,ionic只会创建A.C.D的view,而不会创建A的view。

*注:如果想深究这个优先级是如何实现的话,可研究ionic的transitionTo函数中的如下代码:


...
// We also set up an inheritance chain for the locals here. This allows the view directive
// to quickly look up the correct definition for each view in the current state.
...
for (var l = keep; l < toPath.length; l++, state = toPath[l]) {
  locals = toLocals[l] = inherit(locals);
  resolved = resolveState(state, toParams, state === to, resolved, locals, options);
}

从中可见ionic是通过javascript的继承与原型链实现这种优先级的,子状态的view数据(locals)作为子类覆盖了父状态的数据。

于是scope树变成了这样(假设状态跳转之前transitionCounter为0): 


ion-nav-view name="root"
  |
  |-------- ion-nav-view id="ViewA" 
  |               |
  |               |-------- ion-view view-title="A.B" 
  |
  |-------- ion-view view-title="A.C.D"   transitionId = 1

接下来注意了!A.C.D的view创建之后,遍历还没结束。scope树里还有一个$stateChangeSuccess广播之前就存在的ion-nav-view id="ViewA",而它正好是目标状态A.C.D的父状态A.C的view的容器!因此ionic会继续在ion-nav-view id="ViewA"上创建A.C的view:


ion-nav-view name="root"
  |
  |-------- ion-nav-view id="ViewA" 
  |               |
  |               |-------- ion-view view-title="A.B" 
  |               |
  |               |-------- div transitionId = 2  (这是A.C的view。因为A.C没有定义template,所以它的view只是一个空的div。)
  |
  |-------- ion-view view-title="A.C.D"   transitionId = 1

这时可以发现,最大的transitionId已经不是A.C.D的view,而是A.C的view了。这就是为什么ACDCtrl上没有触发afterEnter的原因。(注:这样说下来,按照流程afterEnter似乎会在A.C的view上触发,但实际上也没有。这是因为afterEnter的触发除了transitionId的判断以外,还有其它更多条件,这些就不在本文中阐述了。)

为什么从状态E跳转到状态A.C.D又没问题呢?因为这种情况下在A.C.D的view创建之后,scope树如下:


ion-nav-view name="root"
  |
  |-------- ion-view view-title="E"
  |
  |-------- ion-view view-title="A.C.D"   transitionId = 1

这时scope树中已经没有其它还未遍历的ion-nav-view了,遍历到此结束。此时transitionId最大的正是A.C.D的view,因此afterEnter也就在ACDCtrl上触发。其实即便scope树中还有其它未遍历的ion-nav-view,只要它们不是A、A.C或A.C.D的容器,那么它们之中就不会创建或唤醒新的view,transitionCounter也就不会增大,afterEnter也还是会在ACDCtrl上触发。

上面阐述的整个遍历和创建view的过程都发生在ionic.bundle.js的transitionTo函数中。这个函数在跳转开始前会调用resolveState记录下目标状态及其祖先状态的各个view需要的容器,然后在跳转成功后会广播$stateChangeSuccess事件,遍历scope树,在最后创建或唤醒的view上触发afterEnter。

因此,如果我们希望A.B -> A.C.D时afterEnter能在ACDCtrl上触发,那么可以更改A.C.D的views定义,将view放在ion-nav-view id="ViewA"上:


.state('A.C.D', {
    url: '/D',
    views: {
      '@A': {  // 'root@'改为'@A'
        templateUrl: 'A/C/D.tpl.html',
        controller: 'ACDCtrl'
      }
    }
  })

这样scope树遍历之后的结果如下:


ion-nav-view name="root"
  |
  |-------- ion-nav-view id="ViewA" 
                  |
                  |-------- ion-view view-title="A.B" 
                  |
                  |-------- ion-view view-title="A.C.D"  transitionId = 1  (A.C.D的view覆盖了A.C的view)

transitionId最大的就是A.C.D的view——实际上也只创建了这一个新view,于是afterEnter就会在ACDCtrl上触发。

或者也可以给A.C的view添加一个ion-nav-view:


.state('A.C', {
    abstract: true,
    url: '/A/C',
    template: '<ion-nav-view></ion-nav-view>'
  })

然后将A.C.D的view放在A.C的view中:


.state('A.C.D', {
    url: '/D',
    views: {
      '@A.C': {
        templateUrl: 'A/C/D.tpl.html',
        controller: 'ACDCtrl'
      }
    }
  })

这样scope树遍历之后的结果如下:


ion-nav-view name="root"
  |
  |-------- ion-nav-view id="ViewA" 
                  |
                  |-------- ion-view view-title="A.B" 
                  |
                  |-------- ion-nav-view  transitionId = 1  (A.C的view)
                                  |
                                  |--------- ion-view view-title="A.C.D"  transitionId = 2

transitionId最大的仍然是A.C.D的view。

注:要控制一个view放在哪个ion-nav-view需要理解view的命名法则,参见ui-router的文档

总而言之,如果我们希望在状态跳转时afterEnter在目标状态的view上触发,那么必须合理安排view的层级,以保证在scope树的深度遍历中,目标状态的view(而不是目标状态的祖先状态的view)是最后一个被创建或唤醒的view。

不过我仍然不太理解为何ionic要用这种方法决定在哪个view上触发afterEnter。为何不在创建view的过程中记下目标状态的view,然后在跳转完成后直接在那个view上触发呢?

最后附上专用于重现该问题的ionic小项目。下载项目解压后,在项目目录下执行ionic serve(需要本机安装ionic)即
会弹出界面。初始状态是E,可以点击按钮在A.B和A.C.D之间跳转。

点击这里下载专用于重现本文所述问题的ionic小项目


本文在我的独立博客上的地址:https://zxtechart.com/2017/03/26/irrational-view-hierarchy-causes-afterenter-not-firing-in-ionic/


            
阅读更多
版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zzxiang1985/article/details/66970321
文章标签: Ionic
个人分类: 错误排查
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

加入CSDN,享受更精准的内容推荐,与500万程序员共同成长!
关闭
关闭