天天品尝iOS7甜点 :: Day 5 :: UIDynamics with Collection Views

这篇文章是天天品尝iOS7甜点系列的一部分,你可以查看完整的系列目录:天天品尝iOS7甜点


回顾前面的章节,我们介绍了动态UIKit的物理引擎,并且我们使用来创建了一个牛顿的实验,尽管那个非常的有趣,但是对于创建一个应用程序来说,并不是十分的明显。今天我们就来做一个实际的应用把物理引擎添加到UICollectionViews中,形成一些明显的效果。

本章的项目就是做一个水平的有弹性的旋转木马,然后会添加他们的动态效果。

完成的代码已经在github可供下载使用:github.com/ShinobiControls/iOS7-day-by-day

为了在一个collection view上面展示物理的效果。首先,我们需要用UICollectionView创建一个旋转木马。本篇并不是着重介绍UICollectionView的教程,所以我们将会跳过创建的部分。我们将会设置collection view的代理(delegate)和数据源(datasource),实现的方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#pargma mark - UICollectionViewDataSource methods
- (NSIneteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
return 1;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return [_collectionViewCellContent count];
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
SCCollectionViewSampleCell *cell = (SCCollectionViewSampleCell *)[self.collectionView dequeuqReusableCellWithReuseIdentifier:@"SpringyCell" forIndexPath:indexPath];
cell.numberLabel.text = [NSString stringWithFormat:@"%d", [_collectionViewCellContent[indexPath.row] integerValue]];
return cell;
}

#pragma mark - UICollectionViewDelegate methods
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
return itemSize;
}

每一个元素都是正方形的,在上面有一个显示数字的UILabel, 元素上面的数字我们保存在一个数组中(_collectionViewCellContent).我们做这些目的是为了能够排序,能够很好的对新插入的数据进行处理。

为了是collection view能够展示成旋转木马的效果,我们需要提供一个自定义的布局,经常用的是流布局,做法如下:

1
2
3
4
5
@interface SCSpringCarousel : UICollectionViewFlowLayout

- (instancetype)initWithItemSize:(CGSize)size;

@end

为了能够是每一个元素都在视图的地步,我们需要知道他们的高度,因此构造器需要元素的大小, 我们重写prepareLayout方法来设置collection view中底部元素的内容大小:

1
2
3
4
5
- (void)prepareLayout {
// We update the section inset before we layout
self.sectionInset = UIEdgeInsetsMake(CGRectGetHeight(self.collectionView.bounds) - _itemSize.height, 0, 0, 0);
[super prepareLayout];
}

按照这样的设置,我们就会创建一个水平的旋转木马的效果。

1
2
3
4
5
6
7
8
- (void)viewDidLoad {
[super viewDidLoad];

...
// Provide the layout
_collectionViewLayout = [[SCSpringCarousel alloc] initWithItemSize:itemSize];
self.collectionView.collectionViewLayout = _collectionViewLayout;
}

Adding springs - 添加弹簧效果

现在让我们来做一些比较振奋人心的事情,让我们为刚刚实现的collection view添加上UIKit动态物理引擎。

这个物理模型会将刚刚创建的每一个元素连接在一个流布局上面,然后使它们的位置附上弹簧的效果。然后,让我们滚动视图的时候,弹簧就会被拉伸,就可以看到它们拉伸的效果,我们需要设置弹簧的拉伸距离和触摸点的距离成正比。

把这个模型转换成动态的概念,我们需要做如下的事情:

  • 我们需要在流布局的超类中获得布局的位置信息
  • 我们需要添加适当的行为到那些位置对象上面,让他们能够在物理的世界里面有动画的效果
  • 这些行为和位置对象都交给animator进行管理,这样仿真效果才可能运行
  • 需要重新重写UICollectionViewLayout的方法来替代从流布局的超类中获取animator的位置信息

上述听起来是一个很复杂的过程,但是,我们需要这么一步步的来实践.

Behavior Manager - 行为管理

为了使代码看来清晰简洁,我们需要创建创建一个类,专门管理动态行为的animator, 代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface SCItemBehaviorManager : NSObject
@property (readonly, strong) UIGravityBehavior *gravityBehavior;
@property (readonly, strong) UICollisionBehavior *collisionBehavior;
@property (readonly, strong) NSDictionary *attachmentBehavior;
@property (readonly, strong) UIDynamicAnimator *animator;

- (instancetype)initWithAnimator:(UIDynamicAnimator *)animator;

- (void)addItem:(UICollectionViewLayoutAttributes *)item anchor:(CGPoint)anchor;
- (void)removeItemAtIndexPath:(NSIndexPath *)indexPath;
- (void)updateItemCollection:(NSArray *)items;
- (NSArray *)currentlyManagedItemIndexPaths;
@end

每一个元素的行为都是有一个共享的UIGravityBehavior,一个共享的UICollisionBehaviro和独立的UIAttachmentBehvaior构成的。我们创建了一个管理行为的UIDynamicAnimator对象和一些其他的方法,例如添加,删除元素,还有一些放来用来更新元素的集合.

当我们创建了一个管理对象,我们需要创建共享的行为,并且把他们添加到animator中:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (instancetype)initWithAnimator:(UIDynamicAnimator *)animator {
self = [super init];
if (self) {
_animator = animator;
_attachmentBehaviors = [NSMutableDictionary dictionary];
[self createGravityBehavior];
[self createCollisionBehavior];
// Add the global behaviors to the animator
[self.animator addBehavior:self.gravityBehavior];
[self.animator addBehavior:self.collisionBehavior];
}
return self;
}

这里调用了两个比较的简单的方法,我们可以在本系类的章节中找到相关的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)createGravityBehavior {
_gravityBehavior = [[UIGravityBehavior alloc] init];
_gravityBehavior.magnitude = 0.3;
}

- (void)createCollisionBehavior {
_collisionBehavior = [[UICollisionBehavior alloc] init];
_collisionBehavior.collisionMode = UICollisionBehaviorModeBoundaries;
_collisionBehavior.translatesReferenceBoundsIntoBoundary = YES;
// Need to add item behavior specific to this
UIDynamicItemBehavior *itemBehavior = [[UIDynamicItemBehavior alloc] init];
itemBehavior.elasticity = 1;
// Add it as a child behavior
[_collisionBehavior addChildBehavior:itemBehavior];
}

你会注意到在这个阶段我们并没有为行为添加任何的动态元素,是因为我们现在确实没有任何可添加的元素。碰撞行为并不能够添加了单个的元素上面,代替的方法就是设置了collection view的边界.因此,我们需要设置两个属性:collisionModetranslatesReferenceBoundsIntoBoundary。我们还需要添加一个UIDynamicItemBehavior来指定碰撞的弹性。

现在我们已经创建了全局的行为,我们需要来实现addItem:removeItem:方法。这个添加的方法将会添加新的元素到全局的行为中并且设置把这个设置这个元素的弹簧效果。

1
2
3
4
5
6
7
8
9
10
11
- (void)addItem:(UICollectionViewLayoutAttributes *)item anchor:(CGPoint)anchor {
UIAttachmentBehavior *aatachmentBehavior = [self createAttachmentBehaviorForItem:item anchor:anchor];
// Add the behavior to the animator
[self.animator addBehavior:attachmentBehavior];
// Add store it in the dictionary. Keyed by the index path
[_attachmentBehaviors setObject:attachmentBehavior forKey:item.indexPath];

// Also need to add this item to the global behaviors
[self.gravityBehavior addItem:item];
[self.collisionBehavior addItem:item];
}

弹性行为的需要用一个工具的方法进行创建:

1
2
3
4
5
6
7
- (UIAttachmentBehavior *)createAttachmentBehaviorForItem:(id<UIDynamicItem>)item anchor:(CGPoint)anchor {
UIAttachmentBehavior *attachmentBehavior = [[UIAttachmentBehavior alloc] initWithItem:item attachedToAnchor:anchor];
attachmentBehavior.damping = 0.5;
attachmentBehavior.frequency = 0.8;
attachmentBehavior.length = 0;
return attachmentBehavior;
}

我们同样需要依附行为保存包一个字典中,用NSIndexPath作为key。这样的话就可以更好的添加和删除节点。

一旦我们创建了依附行为,我们需要把它添加到animator中,并且还需要把他们添加到共享的重力和碰撞行为中。

删除方法执行完全相反操作,删除附件行为动画师和项目从共享重力和碰撞行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)removeItemAtIndexPath:(NSIndexPath *)indexPath {
// Remove the attachment behavior from the animator
UIAttachmentBehavior *attachmentBehavior = self.attachmentBehaviors[indexPath];
[self.animator removeBehavior:attachmentBehavior];

// Remove the item from the global behaviors
for (UICollectionViewLayoutAttributes *attr in [self.gravityBehavior.items copy]) {
if ([attr.indexPath isEqual:indexPath]) {
[self.grvityBehavior removeItem:attr];
}
}
for (UICollectionViewLayoutAttributes *attr in [self.collisionBehavior.items copy]) {
if ([attr.indexPath isEqual:indexPath]) {
[self.collisonBehavior removeItem:attr];
}
}

// And remove the entry from our dictionary
[_attachmentBehaviors removeObjectForKey:indexPath];
}

这种方法比我们想象的稍微复杂一点。删除依附的行为是我们所期望的,但是从共享的行为中删除元素有一点儿复杂。元素对象被复制,并且有不同的引用。所以我们就需要查找重力行为中的对象,并且通过相同的index path删除元素对象。因此我们需要遍历来进程查找出相同index path的对象。

下面我们再看看看另外的一个行为管理方法updateItemCollection:,这个方法的入参是一个元素对象的集合,然后通过修正过的参数来调用addItem:anchor:removeItem:确保管理的是当前的修改的对象。我们将会看到为什么这样做是有效的,但是首先让我们看看是如何实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)updateItemCollection:(NSArray *)items {
// Let's find the ones we need to remove. We work in indexPaths here
NSMutableSet *toRemove = [NSMutableSet setWithArray:[self.attachmentBehaviors allKeys]];
[toRemove minusSet:[NSSet setWithrray[items valueForKeyPath:@"indexPath"]]];

// Let's remove any we no longer need
for (NSIndexPath *indexPath in toRemove) {
[self removeItemAtIndexPath:indexPath];
}

// Find the items we need to add springs to. A bit more complicated =(
// Loop through the items we want
NSArray *existingIndexPaths = [self currentlyManagedItemIndexPaths];
for (UICollectionViewLayoutAttributes *attr in items) {
// Find whether this item matches an existing index path
BOOL alreadyExists = NO;
for (NSIndexPath *indexPath in existingIndexPaths) {
if ([indexPath isEqual:attr.indexPath]) {
alreadyExists = YES;
}
}

// If it doesn't when let's add it
if (!alreadyExists) {
// Need to add
[self addItem:attr anchor:attr.center];
}
}
}

这是一个比较简单的方法,我们首先找出我们需要删除的元素,通过一些运算({items we currently have}/{items we should have})。然后循环结果的集合调用removeItem:方法。

我们需要把新加入的元素(没有存在行为管理里面的)进行行为管理.所以我们调用addItem:anchor:方法。值得注意的是这里的锚点是在UIDynamicItem对象中提供的当前中心位置。对于UICollectionView而言,我们需要把这些位置锚点在流布局上面。

Using the manager in the collection view layout - 在collection view布局上面使用行为管理

到目前为止,我们已经创建了行为管理类,而且也实现了我们需要的UIDynamics代码。接下来的任务就是要把它们与collection view layout关联在一起。这需要在SCSpringyCarousel中覆盖UICollectionViewFlowLayout中的方法。

我们已经覆盖了prepareLayout方法来编写一个水平形式的木马。现在我们需要继续修改这些代码来确保让animator来控制这些动态的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)prepareLayout {
// We update the section inset before we layout
self.sectionInset = UIEdgeInsetsMake(CGRectGetHeight(self.collectionView.bounds) - _itemSize.height, 0, 0, 0);
[super prepareLayout];

// Get a list of the objects around the current view
CGRect expandedViewPort = self.collectionView.bounds;
expandedViewPort.origin.x -= 2 * _itemSize.width;
expandedViewPort.size.width += 4 * _itemSize.width;
NSArray *currentItem = [super layoutAttributesForElementsInRect:expandedViewPort];

// We update our behavior collection to just contain the objects we can currently (almost) see
[_behaviorManager updateitemCollection:currentItems];
}

前一行代码就是我们之前编写的,然后我们需要扩展viewport的大小.这是b包含当前的viewport向左和向右拉伸,确定元素能够在动态的animator控制下展示在屏幕上面。一旦我们从超类中获取布局属性,然后所有的元素就会以矩形的形式出现在屏幕上面。和UIView一样,UICollectionViewLayoutAttributes也是实现了UIDynamicItem协议,因此可以被UIDynamicAnimator进行管理动画。我们把这些布局上面的元素取出来交给animator进行管理,这样就能够添加一些动画效果。

下面一个需要复写的方法是shouldInvalidateLayoutForBoundsChange:,我们实际上并不想改变这个方法的行为(默认的返回值是NO,这个我们不需要修改的),但是这个方法会在collection view变化的时候进行调用。在scroll view的世界里,属性bounds代表了当前的viewport的位置,例如,x和y的值都不是0.然而,属性bounds变化了实际上是由于UIScrollView滚动了导致的。

这个方法的内部实现是比较复杂的,所以我们需要一步步的深入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {

CGFloat scrollDelta = newBounds.origin.x - self.collectionView.bounds.origin.x;
CGPPoint touchLocation = [self.collectionView.panGestureRecognizer locationInView:self.collectionView];

for (UIAttachmentBehavior *behavior in [_behaviorManager.attachmentBehaviors allValues]) {
CGPoint anchorPoint = behavior.anchorPoint;
CGFloat distFromTouch = ABS(anchorPoint.x - touchLocation.x);

UICollectionViewLayoutAttributes *attr = [behavior.items firstObject];
CGPoint center = attr.center;
CGFloat scrollFacor = MIN(1, distFromTouch / 500);

center.x += scrollDelta * scrollFactor;
attr.center = center;

[_dynamicAnimator updateItemUsingCurrentState:attr];
}

return NO;
}
  1. 首先,我们要计算出滚动的偏移是多少,计算出来的值,我们需要在后面运用弹簧效果的时候使用。
  2. 我们需要通过手势操作找出我们当前触摸在collection view上面的点的位置
  3. 现在我们需要循环每一个行为管理的对象,更新它们.
  4. 首先,我们需要找出我们的触摸点和锚点之间的水平距离。因为我们将会使用这个值来实现弹簧的拉伸效果。
  5. 然后我们需要计算出当前元素的新位置,使用上面计算出来的scrollDeltascrollFactor.
  6. 我们告诉动态的animator来属性元素的状态.当一个元素添加到动态的animator中,animator就是复制元素的状态,然后就会对他进行动画处理,为了推动新状态我们更新theUIDynamicItem属性,然后告诉animator应该重新加载这个元素的状态。
  7. 最后我们返回NO - 我们已经让动态animator管理了所有cell的位置,我们不需要重新加载布局。

还有其他的两个方法我们需要复写,目的都是消除流布局的控制,把他们交给动态animator进行管理:

1
2
3
4
5
6
7
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
return [_dynamicAnimator itemsInRect:rect];
}

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
return [_dynamicAnimator layoutAttributesForCellAtIndexPath:indexPath];
}

动态的animator有两个辅助方法来实现这样的目的,这将会很好的插入集合视图布局类。这些方法被collection view用来定位cell的位置。我们简单的动过animator来返回cell的位置。

Test run - 运行测试

OK,如果你运行项目就可以看到一个旋转木马的页面,你拖拽的话,就可以看到他们的弹簧效果。

Inserting items - 添加新元素

现在我们可以看到运行的效果了,我们将会更加深入的看看通过动态的animator今天添加一个新的元素是多么的复杂。我们确实做了很多的工作,让我们来看看我们还需要做些什么。

标准的UICollectionView,布局为显示的元素提供了布局属性,并且通过collection让元素运动到最后的位置(具体的位置是由layoutAttributesForItemAtIndexPath:返回的)。然而,我们使用UIDynamicAnimator执行动画效果,所以我们需要阻止UIView的动画。为了达到这个目的,我们需要在prepareLayout方法的最后添加上如下的代码:

1
[UIView setAnimationsEnabled:NO];

这就保证了我们没有两个动画进程进行相互的干扰。

如前所述,UICollectionViewLayout会调用方法来定位新的元素的位置,使用initialLayoutAttributesForAppearingItemAtIndexPath:方法。我们要让我们的动画处理:

1
2
3
- (UICollectionViewLayoutAttributes *)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)itemIndexPath {
return [_dynamicAnimator layoutAttributesForCellAtIndexPath:itemIndexPath];
}

现在我们需要需要通知animator一个新的元素添加了,适当更新原来已有的元素的位置,并且设置新增的元素。为了达到这个目的,我们需要复写SCSpringyCarousel来中的prepareForCollectionViewUpdates:的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems {
for (UICollectionViewUpdateItem *updateItem in updateItems) {
if (updateItem.updateAction == UICollectionUpdateActionInsert) {
// Reset the springs of the existing items
[self resetItemSpringsForInsertAtIndexPath:updateItrm.indexPathAfterUpdate];

// Where would the flow layout like to place the new cell?
UICollectionViewLayoutAttributes *attr = [super initialLayourAttributesForAppearingItemAtIndexPath:updateItem.indexPathAfterUpdate];
CGPoint center = attr.center;
CGSize contentSize = [self collectionViewContentSize];
center.y -= contentSize.height - CGRectGetHeight(attr.bounds);

// Now reset the center of insertion point for the animator
UICollectionViewLayoutAttributes *insertionPointAttr = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathAfterUpdate];
insertionPointAttr.center = center;
[_dynamicAnimator updateItemUsingCurrentState:insertionPointAttr];
}
}
}

这是一个内容很多的方法,但是我们可以把它分解成许多简单的部分:

  1. 这个方法会被添加,移除效果和移动等状态调用。我们只对添加对象感兴趣,所以我们只需要判断出更新状态UICollectionUpdateActionInsert来做一些操作。
  2. 让新增操作发生,collection view将会重新分配布局来修改元素的索引值和它相邻元素的索引值(例如:如果当前插入的索引值是4,则当前元素5的元素就会设置索引值为6).在我们的场景中,我们想要保持锚点的行为和相邻布局有关,但是位置必须是自己的,而不是相邻的。我们执行这个使用了一个工具的方法resetItemSpringsForInsertAtIndexPath:,这个我们等会在细看
  3. 现在,我们需要处理的新的元素已经被添加了。我们需要告诉流布局如何放置它。我们希望它出现在collection view的上方,然后animator会通过重力行为把它拖拽下来。我们计算出添加的元素放置的中心位置。
  4. 现在我们要求animator管理刚刚我们插入的index path, 然后更新位置和我们刚刚计算的相匹配。

最后这个这个方法是我们上述提到过的,用来更新弹簧的空隙,让新的元素可以填充其中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)resetItemSpringsForInsertAtIndexPath:(NSIndexPath *)indexPath {
// Get a list of items, sorted by their indexPath
NSArray *items = [_behaviorManager currentlyManagedItemIndexPaths];
// Now loop backwards, updating centers appropriately;
// We need to get 2 enumerators - copy from one to the other
NSEnumerator *fromEnumerator = [items reverseObjectEnumerator];
// We want to skip the lastmost object in the array as we're copying left to right
[fromEnumerator nextObject];
// Now enumarate the array - through the 'to' positions
[items enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
NSIndexPath *toIndex = (NSIndexPath *)obj;
NSIndexPath *fromIndex = (NSIndexPath *)[fromEnumerator nextObject];

// If the 'from' cell is after the insert then need to reset the springs
if (fromIndex && fromIndex.item >= indexPath.item) {
UICollectionViewLayoutAttributes *toItem = [self layoutAttributesForItemAtIndexPath:toIndex];
UICollectionViewLayoutAttributes *fromItem = [self layoutAttributesForItemAtIndexPath:fromIndex];
toItem.center = fromItem.center;
[_dynamicAnimator updateItemUsingCurrentState:toItem];
}
}];
}

我们还需要添加一个按钮用来添加新的元素,我们在storyboard中今天添加,具体触发的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (IBAction)newViewButtonPressed:(id)sender {
// What's the new number we're creating?
NSNumber *newTitle = @([_collectionViewCellContent count]);

// We want to place it in at the correct position
NSIndexPath *rightOfCenter = [self indexPathOfItemRightOfCenter];

// Insert the new item content
[_collectionViewCellContent insertObject:newTitle atIndex:rightOfCenter.item];

// Redraw
[self.collectionView insertItemsAtIndexPaths:@[rightOfCenter]];
}

这里有一个工具的方法来计算出右边可见元素的中心位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (NSIndexPath *)indexPathOfItemRightOfCenter {
// Find all the currently visible items
NSArray *visibleItems = [self.collectionView indexPathsForVisibleItems];

// Calculate the middle of the current collection view content
CGFlot midx = CGRectGetMidX(self.collectionView.bounds);
NSUinteger indexOfItem;
CGFloat curMin = CGFLOAT_MAX;

// Loop through the visible cells to find the left of center one
for (NSIndexPath *indexPath in visibleItems) {
UICollectionViewCell *cell = [self.collectionView cell ForItemAtIndexPath:indexPath];
if (ABS(CGRectGetMidX(cell.frame) - midX) < ABS(curMin)) {
curMin = CGRectGetMidX(cell.frame) - midX;
indexOfItem = indexPath.item;
}
}

// If min is -ve then we have left of centre. If +ve then we have right of centre.
if (curMin < 0) {
indexOfItem += 1;
}

// And now get the index path to pass back
return [NSIndexPath indexPathForItem:indexOfItem inSection:0];
}

到此为止就结束了,重新运行app,尝试添加元素,然后可以看到牛X的效果,在滚动视图的时候同时添加元素,同样可以看待比较强大的动画效果,这就是dynamic animator的强大之处.

Conclusion - 总结

这篇里面我们已经介绍了UIKit Dynamics的物理引擎是如此的简单,但是今天我们的例子是一个面向现实世界的。这样来做其实是有点儿复杂,但是我们建议你是用一些这样的效果,因为这样可以取悦用户。

本文翻译自:iOS7 Day-by-Day :: Day 5 :: UIDynamics with Collection Views

文章目录
  1. 1. Building a Carousel - 创建旋转木马
  2. 2. Adding springs - 添加弹簧效果
    1. 2.0.1. Behavior Manager - 行为管理
    2. 2.0.2. Using the manager in the collection view layout - 在collection view布局上面使用行为管理
  • 3. Test run - 运行测试
  • 4. Inserting items - 添加新元素
  • 5. Conclusion - 总结
  • ,