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

文章原链接:http://blog.csdn.net/zzxiang1985/article/details/66970321

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

 

[javascript] view plain copy
 
  1. $stateProvider  
  2.   .state('A', {  
  3.     abstract: true,  
  4.     views: {  
  5.       root: {  
  6.         template: '<ion-nav-view id="ViewA"></ion-nav-view>'  
  7.       }  
  8.     }  
  9.   })  
  10.   .state('A.B', {  
  11.     url: '/A/B',  
  12.     templateUrl: 'A/B.tpl.html',  
  13.     controller: 'ABCtrl'  
  14.   })  
  15.   .state('A.C', {  
  16.     abstract: true,  
  17.     url: '/A/C'  
  18.   })  
  19.   .state('A.C.D', {  
  20.     url: '/D',  
  21.     views: {  
  22.       'root@': {  
  23.         templateUrl: 'A/C/D.tpl.html',  
  24.         controller: 'ACDCtrl'  
  25.       }  
  26.     }  
  27.   })  
  28.   .state('E', {  
  29.     url: '/E',  
  30.     views: {  
  31.       root: {  
  32.         templateUrl: 'E.tpl.html'  
  33.       }  
  34.     }  
  35.   })  

 

 

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

 

 

[html] view plain copy
 
  1. <html>  
  2.   ...  
  3.   <body ng-app="starter">  
  4.     ...  
  5.     <ion-nav-view name="root"></ion-nav-view>  
  6.   </body>  
  7. </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:

 

 

[plain] view plain copy
 
  1. $ ionic lib  
  2. Local Ionic version: 1.3.1 (/Users/zhixiangzhu/my-ionic-project/www/lib/ionic/version.json)  
  3. Latest Ionic version: 1.3.3 (released 2017-02-24)  
  4.  * Local version is out of date  

 

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

 

[plain] view plain copy
 
  1. $ ionic lib  
  2. Local Ionic version: 1.3.3 (/Users/zhixiangzhu/ionic-afterEnter-test/www/lib/ionic/version.json)  
  3. Latest Ionic version: 1.3.3 (released 2017-02-24)  
  4.  * Local version up to date  

 

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

 

 

[javascript] view plain copy
 
  1. function transitionComplete() {  
  2.   ...  
  3.   // the most recent transition added has completed and all the active  
  4.   // transition promises should be added to the services array of promises  
  5.   if (transitionId === transitionCounter) {  
  6.     ...  
  7.     // emit that the views have finished transitioning  
  8.     // each parent nav-view will update which views are active and cached  
  9.     switcher.emit('after', enteringData, leavingData);  // ionic在这里触发afterEnter            
  10.     ...  
  11.   }  
  12.   ...  
  13. }  


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

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

 

 

[javascript] view plain copy
 
  1. IonicModule.factory('$ionicViewSwitcher', [  
  2. ...,  
  3. function(...) {  
  4.   ...  
  5.   var transitionCounter = 0;  
  6.   ...  
  7.   
  8.   var ionicViewSwitcher = {  
  9.   
  10.     create: function(navViewCtrl, viewLocals, enteringView, leavingView, renderStart, renderEnd) {  
  11.       // get a reference to an entering/leaving element if they exist  
  12.       // loop through to see if the view is already in the navViewElement  
  13.       var enteringEle, leavingEle;  
  14.       var transitionId = ++transitionCounter;  


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

 

 

 

[javascript] view plain copy
 
  1. IonicModule  
  2. .directive('ionNavView', [  
  3.   ...,  
  4. function(...) {  
  5.   // IONIC's fork of Angular UI Router, v0.2.10  
  6.   // the navView handles registering views in the history and how to transition between them  
  7.   return {  
  8.     ...  
  9.     // listen for $stateChangeSuccess  
  10.     $scope.$on('$stateChangeSuccess', function() {  
  11.       updateView(false);  
  12.     });  
  13.     ...  
  14.     function updateView(firstTime) {  
  15.       // get the current local according to the $state  
  16.       var viewLocals = $state.$current && $state.$current.locals[viewData.name];  
  17.   
  18.       // do not update THIS nav-view if its is not the container for the given state  
  19.       // if the viewLocals are the same as THIS latestLocals, then nothing to do  
  20.       if (!viewLocals || (!firstTime && viewLocals === latestLocals)) return;  
  21.   
  22.       // update the latestLocals  
  23.       latestLocals = viewLocals;  
  24.       viewData.state = viewLocals.$$state;  
  25.   
  26.       // register, update and transition to the new view  
  27.       navViewCtrl.register(viewLocals);  // ionicViewSwitcher的create函数是从这里一路调用进去的  
  28.     }  
  29.     ...  


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

 

 

 

[javascript] view plain copy
 
  1. var transition = $state.transition = resolved.then(function () {  
  2.   ...  
  3.   $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams);  
  4.   ...  


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

 


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

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

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.  |  
  3.  |-------- 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):

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-view state="E"  
  4.   |  
  5.   |-------- 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树如下所示:

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-view state="E"  
  4.   |  
  5.   |-------- ion-nav-view id="ViewA"   transitionId = 1   
  6.                   |  
  7.                   |-------- 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树是这样的:

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-nav-view id="ViewA"   
  4.                   |  
  5.                   |-------- 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函数中的如下代码:

 

 

 

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


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

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

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-nav-view id="ViewA"   
  4.   |               |  
  5.   |               |-------- ion-view view-title="A.B"   
  6.   |  
  7.   |-------- 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:

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-nav-view id="ViewA"   
  4.   |               |  
  5.   |               |-------- ion-view view-title="A.B"   
  6.   |               |  
  7.   |               |-------- div transitionId = 2  (这是A.C的view。因为A.C没有定义template,所以它的view只是一个空的div。)  
  8.   |  
  9.   |-------- 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树如下:

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-view view-title="E"  
  4.   |  
  5.   |-------- 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"上:

 

 

 

[javascript] view plain copy
 
  1. .state('A.C.D', {  
  2.     url: '/D',  
  3.     views: {  
  4.       '@A': {  // 'root@'改为'@A'  
  5.         templateUrl: 'A/C/D.tpl.html',  
  6.         controller: 'ACDCtrl'  
  7.       }  
  8.     }  
  9.   })  


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

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-nav-view id="ViewA"   
  4.                   |  
  5.                   |-------- ion-view view-title="A.B"   
  6.                   |  
  7.                   |-------- 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:

 

 

[javascript] view plain copy
 
  1. .state('A.C', {  
  2.     abstract: true,  
  3.     url: '/A/C',  
  4.     template: '<ion-nav-view></ion-nav-view>'  
  5.   })  


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

 

 

 

[javascript] view plain copy
 
  1. .state('A.C.D', {  
  2.     url: '/D',  
  3.     views: {  
  4.       '@A.C': {  
  5.         templateUrl: 'A/C/D.tpl.html',  
  6.         controller: 'ACDCtrl'  
  7.       }  
  8.     }  
  9.   })  


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

 

 

 

[plain] view plain copy
 
  1. ion-nav-view name="root"  
  2.   |  
  3.   |-------- ion-nav-view id="ViewA"   
  4.                   |  
  5.                   |-------- ion-view view-title="A.B"   
  6.                   |  
  7.                   |-------- ion-nav-view  transitionId = 1  (A.C的view)  
  8.                                   |  
  9.                                   |--------- 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/

posted @ 2017-09-18 11:26  callmeguxi  阅读(319)  评论(0编辑  收藏  举报