iOS 对 Cell 的相关封装和复用

·  阅读 11003

开局

我们从 Cell 出发.

在很长一段时间里, 我都是将 Model 直接设置给 Cell, 然后重写 setModel: 在里面进行各种判断来设置 Cell 内容, 代码片段如下:

@interface Model
// ...
// ...
@end

@interface Cell
// ...
// ...
@property (nonatomic, strong, nullable) Model *model;
@end

@implementation Cell
// ...
// ...
- (void)setModel:(Model *)model {
    // ...
    
    _stateLabel.text = model.isLiked ? @"已点赞" : "点赞";
}
@end
复制代码

像这里的 Model, 一般都是从网络请求或其他方式搞到的原始数据, 直接拿来使还不得劲, 需要根据业务来设置显示内容, 简单点的业务还好, 如果是复杂的业务这里可能就变成了一堆判断了.

Level 1 DataSource

上面这个模式写的多了渐渐的会发现一些弊端: 复用 Cell 需要特定的 Model 才行, 勉强搞了对应 Model(可能还会给这个 Model 添加新的属性) 想要在 setModel: 中添加新的业务时, 总会担心影响旧的逻辑.

我琢磨着如何解决这些问题. 从 Cell 的角度来看, 它需要的是一些显示数据以及将交互事件传递出来, 针对显示数据, 最简单的方式就是需要什么, 就提供什么, 请看以下代码片段:

@protocol DataSource
// ...
// ...
@property (nonatomic, copy, readonly) NSAttributedString *likingStateText;
@end

@protocol Delegate;

@interface Cell
@property (nonatomic, weak, nullable) id<DataSource> dataSource; 
@property (nonatomic, weak, nullable) id<Delegate> delegate; 
@end
 
@implementation Cell
- (void)setDataSource:(id<DataSource>)dataSource {
    // …
    // …
    _stateLabel.attributedText = dataSource.likingStateText;
}
@end
复制代码

如上, 移除 Model 绑定, 添加 DataSource, 仅声明 Cell 需要的数据, 例如 Cell 需要一个富文本 likingStateText, 那我们就在 DataSource 中添加一个对应属性.

这样子 Cell 仅知道自己需要的数据, 因此业务处理在 Cell 中就消失了, 转而移到了 DataSource 的实现中了.

Level 2 Item 模式

目前对任何实现了 DataSource 的类的对象都可以设置给 Cell, 所以针对不同的业务逻辑可以有多个 DataSource 的实现, Cell 也因此得到了最大程度的复用.

以下是结合原始数据以及业务逻辑来实现协议 DataSource 的代码片段:

@interface Model
// 原始数据
// ...
// ...
@end 

@protocol DataSource
// ...
// ...
@property (nonatomic, copy, readonly) NSAttributedString *likingStateText;
@end

// Item 结合原始数据, 实现了 DataSource
@interface Item<DataSource>
- (instancetype)initWithModel:(Model *)model;
@end

@implementation Item
- (instancetype)initWithModel:(Model *)model {
    // ... 部分业务逻辑
    // ...
    _likingStateText = [NSAttributedString.alloc initWithString:model.isLiked ? @"已点赞" : @"点赞"];
}
@end
复制代码

数据流向如图: 数据流向.png

如上, Item 实现了协议 DataSource, 并在初始化时传入原始数据 Model 进行了业务逻辑的处理.

通过对 DataSource 的不同实现, 使得业务的处理能够分散给不同的 Item. 相对于 Cell 来说, 它看到的永远是 DataSource.

Level 3 Item 升级-注册信息

在我看来 Item 的出现是必然的, 它的能力也并不仅仅于此.

我们都知道 Cell 在使用之前需要先注册到 CollectionView 中, 而后还会涉及到 size 的计算.

我先拿注册 Cell 来说, 注册 Cell 无非就是用 Identifier 以及 Class 或 Nib 来注册, 而 Item 是最了解 Cell 的, 不如将注册信息做成 Item 的属性, 之后再找个时机读取这些信息进行注册, 对 Item 的扩展代码如下:

@interface Item<DataSource>
// ...
// ...
@property (nonatomic, readonly) Class cellClass; // cell 的类;
@property (nonatomic, readonly, nullable) UINib *cellNib; // 如果是xib, 就返回 nib;
@end

@implementation Item
- (Class)cellClass {
    return DemoCollectionViewCell.class;
}

- (UINib *_Nullable)cellNib {
    return nil;
}
@end
复制代码

你可能会注意到好像漏了 Identifier 属性, 其实这个属性并不是必须的, 类名本身就是唯一的, NSStringFromClass(cellClass) 已经是最好的 Identifier 了.

解决了注册信息的问题, 先暂时抛开注册时机, 我们来看看 size 的计算, 同样由于 Item 对 Cell 了如指掌, 它一定知道如何计算合适的 size, 所以我们再次扩展 Item 如下:

@interface Item<DataSource>
// ...
// ...
- (CGSize)layoutSizeThatFits:(CGSize)size atIndexPath:(nullable NSIndexPath *)indexPath collectionViewScrollDirection:(UICollectionViewScrollDirection)scrollDirection;
@end
复制代码

这个方法类似于 UIView.sizeThatFits:, Item 在这个方法里返回最适合的 size 用于 Cell 布局.

Level 4 Item 升级-动态性

在此之前, 业务处理都是在 Item 初始化构建过程中完成的, 对数据的处理还缺少一些动态性. 例如想要修改 Item 某个属性, 并及时的更新 Cell, 这个时候该怎么办?

如果要做到及时更新, Item 就需要绑定一个 Cell 才行, 当 Item 属性被修改以后, 调用 Cell 相关方法刷新显示, 下面是对 Item 的扩展代码:

@interface Cell
// ...
// ...
- (void)reloadLikingStatus;
@end

@interface Item<DataSource>
// ...
// ...
- (void)bindCell:(__kindof UICollectionViewCell *)cell atIndexPath:(NSIndexPath *)indexPath;
- (void)unbindCell:(__kindof UICollectionViewCell *)cell atIndexPath:(NSIndexPath *)indexPath;

@property (nonatomic, getter=isLiked) BOOL liked; // 想要变更的属性
@end

@implementation Item {
    __weak DemoCollectionViewCell *_cell;
}

// ...
// ...
- (void)bindCell:(Cell *)cell atIndexPath:(NSIndexPath *)indexPath {
    cell.dataSource = self;
    _cell = cell; // 绑定
}

- (void)unbindCell:(__kindof UICollectionViewCell *)cell atIndexPath:(NSIndexPath *)indexPath {
    _cell = nil; // 解绑
}

- (void)setLiked:(BOOL)isLiked {
    // ...
    // ...
    if ( _cell != nil ) [_cell reloadLikingStatus]; // 刷新 cell 显示
}
@end
复制代码

如上, 在更新 Item 某个属性时, 通过重写相应的 set 方法及时的刷新 Cell.

当通过上述方案增加动态性后, 随着数据内容的改变很有可能会使 size 也发生改变. 而在实际的应用场景中, 我们为了避免重复计算 size, 一般会缓存每个 Item 的 size, 因此需要提供一种能够刷新缓存的机制, 标识出哪个 Item 需要刷新 size, 由于修改属性的动作 Item 是有感知的(可以重写属性的 set 方法), 所以就让 Item 自己来标识, 扩展代码如下:

@interface Item<DataSource>
// ...
// ...
@property (nonatomic, readonly) BOOL needsLayout;
- (void)setNeedsLayout;
@end

@implementation Item
- (instancetype)initWith... {
    // ...
    // ...
    [self setNeedsLayout]; 
}

// 调用后代表需要刷新 size
- (void)setNeedsLayout {
    _needsLayout = YES;
}

- (CGSize)layoutSizeThatFits:(CGSize)size atIndexPath:(nullable NSIndexPath *)indexPath collectionViewScrollDirection:(UICollectionViewScrollDirection)scrollDirection {
    // ...
    // ...
    // 这个方法被调用, 就代表这里正在计算最新的 size; 
    // 由于已是最新 size, 此时可以将 _needsLayout 重置为 NO.
    _needsLayout = NO;
}

- (void)setLiked:(BOOL)isLiked {
    // ...
    // ...
    // 此处模拟内容发生改变后, size 也需要重新计算的场景;
    // 调用`setNeedsLayout`标识需要重新计算 size;
    [self setNeedsLayout];
}
@end
复制代码

上面这个处理机制类似于 UIView.setNeedsLayoutCALayer.setNeedsLayout, 相当于做了个标记到下次运行循环时执行更新操作.

Level 5 Item 升级-事件处理

现在我们再回到对 Cell 事件的处理上.

按之前旧模式的写法, 最终会在 VC 中为 Cell 设置 Delegate. 以下是旧模式的一些代码片段:

// 这是旧的模式, 供后续分析使用
// -------------- cell --------------
@protocol Delegate
// ...
// ...
- (void)likingItemWasTappedOnCell:(Cell *)cell; //
@end

@interface Cell
// ...
// ...
@property (nonatomic, weak, nullable) id<Delegate> delegate;
@end

// -------------- vc --------------
#import "Cell.h"

@interface ViewController<Delegate>
@end
@implementation ViewController
// ...
// ...
- (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath {
    // ...
    // ...
    // 设置 delegate
    if ( [cell isKindOfClass:Cell.class] ) [(Cell *)cell setDelegate:self]; 
}

// Cell - Delegate methods
- (void)likingItemWasTappedOnCell:(Cell *)cell {
    // ...
    // ...
}
@end
复制代码

在这个模式中, VC 与 Cell 将会直接进行交互, 包括从导入头文件, 到注册, 再到设置模型和代理并实现代理方法以及在代理方法中针对业务上的一些处理和交互.

上面仅仅演示了一种 Cell 的情况, 实际开发中, 随着业务复杂度的上身, Cell 会越来越多, 在 VC 中进行维护会感觉越来越吃力, 修改可能会变得异常困难.

我们再次回到 Item 模式, 由前面所说的 Item 已经能够提供 Cell 注册时需要的信息以及替代设置模型的能力(并且拥有了动态性, 修改属性后可以刷新显示), 对了, 还有 size 计算的能力, 同时针对业务也会封装在 Item 内部. 那么剩下的 Cell.Delegate 是否也可以通过 Item 进行设置?

我们从 Item 的角度看一下 DataSource 与 Delegate, DataSource 面向的是 Cell, 那 Delegate 面向的就是 VC 了, 我们在 Item 层实现 DataSource, 那如果也实现 Delegate 呢, 将事件转发给 VC 这条路实际上也行得通, 扩展 Item 的代码片段如下:

// -------------- DemoItem.h --------------
// 在这里做一层继承, 在此之前 Item 的相关能力可以按抽象类来设计的, 包含一些抽象方法
// DemoItem 将继承 Item 的能力, 作为 Item 的一个具体实现类
@interface DemoItem : Item
// ...
// ...
@property (nonatomic, copy, nullable) void(^likingHandler)(DemoItem *item);
@end

// -------------- DemoItem.m --------------
#import "Cell.h" // 在.m中导入cell头文件(cell 并不需要暴露在 item.h 中)
@interface DemoItem()<DataSource, Delegate>
// ...
// ...
@end

@implementation DemoItem 
// ... 
// ...
- (void)bindCell:(Cell *)cell atIndexPath:(NSIndexPath *)indexPath {
    cell.dataSource = self;
    cell.delegate = self; // 此处设置 cell.delegate
    _cell = cell; // 
}
// Cell - Delegate methods
- (void)likingItemWasTappedOnCell:(Cell *)cell {
    if ( _likingHandler != nil ) _likingHandler(self);
}
@end

// -------------- ViewController.m --------------
@implementation ViewController
// ...
// ...
- (void)_setupItems {
    // ...
    // ...
    NSArray<DemoItem *> *demoItems = ...;
    for ( DemoItem *item in demoItems ) {
        item.likingHandler = ^(DemoItem *item) {
            // ...
            // ...
            BOOL isLiked = !item.isLiked;
            // ... 在此进行网络请求后, 更新 item 属性
                item.liked = isLiked; 
        }
    }
}
@end
复制代码

事件传递方向.png

如上 Item 内部进行 Cell.Delegate 的设置, 并将相应事件作为 block 属性对外提供. 在 VC 中, 通过设置Item.block 进行业务的处理.

到了这一步, 对 VC 来说它可能是感知不到 Cell 的存在, VC 仅与 Item 通过 block 进行事件的交互, 并在某个时机设置 Item 的属性或调用 Item 的方法, 间接的就能完成对 Cell 的刷新.

Cell 的代理事件我们处理完了, 到这里你可能还会想到 UICollectionView 的代理方法中collectionView:didSelectItemAtIndexPath:该如何处理? 按照旧模式的写法这个方法里面会根据 indexPath 做一些判断来处理点击事件, 同样堆满各种判断. 但把这个点击事件交给 Item 处理会是什么样的, 请看代码片段如下:

// -------------- Item.h --------------
@interface Item
// ...
// ...
@property (nonatomic, copy, nullable) void(^selectionHandler)(__kindof Item *item, NSIndexPath *indexPath);
@end

// -------------- ViewController.m --------------
@implementation ViewController
// ...
// ...
- (void)_setupItems {
    // ...
    // ...
    NSArray<DemoItem *> *demoItems = ...;
    for ( DemoItem *item in demoItems ) {
        // 设置点击处理
        item.selectionHandler = ^(DemoItem *item) {
            // ...
            // ...
        }
    }
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    // 获取对应位置的 item
    // itemAtIndexPath: 这个方法仅做模拟, 后续会介绍如何管理并获取 Item
    Item *item = [self itemAtIndexPath:indexPath];
    if ( item.selectionHandler != nil ) item.selectionHandler(item, indexPath);
}
@end
复制代码

如上, Item 新增selectionHandler, VC 中在合适位置进行配置, 最后在代理方法中进行调用.

Level 6 对 Item 进行管理-Section

Item 此时可以说是具备完备的功能了, 但是它仅代表单个个体, 我们将来需要的是一组或更多组 Item, 需要有个类来负责进行管理(类似数组可以对元素提供 添加, 删除, 获取等的能力).

我们看 CollectionView 的数据是由多个 Section 组成, 而 Section 由 SectionHeader, Cell, SectionFooter 组成, 另外它们还都可以添加 Decoration.

管理 Item 自然就交给 Section 来负责了, 代码片段如下:

@interface Section : NSObject
@property (nonatomic) CGFloat minimumInteritemSpacing;
@property (nonatomic) CGFloat minimumLineSpacing;
@property (nonatomic) UIEdgeInsets sectionInsets; // 内间距.

@property (nonatomic, strong, nullable) __kindof SectionHeaderFooter *header;
@property (nonatomic, strong, nullable) __kindof SectionHeaderFooter *footer;

// 对 item 进行管理
@property (nonatomic, readonly) NSInteger numberOfItems;
- (NSInteger)addItem:(Item *)item;
- (nullable NSIndexSet *)addItemsFromArray:(NSArray<Item *> *)items;
- (NSInteger)insertItem:(Item *)item atIndex:(NSInteger)index;
- (NSInteger)moveItem:(Item *)item toIndex:(NSInteger)index;

- (NSInteger)removeItemAtIndex:(NSInteger)index;
- (nullable NSIndexSet *)removeItemsInArray:(NSArray<Item *> *)items;
- (void)removeAllItems;

- (void)setNeedsLayout;

// ...
// ...
@end
复制代码

这里说一下, 在上面的代码片段里, 不仅有 item, 还出现了 header, footer. 很显然, 不仅仅可以用 Item 封装 Cell, HeaderFooterView 这些视图也可以进行类似的封装.

Section 不展开细说了, 我们直接看定义的接口吧, 其中minimumInteritemSpacing, minimumLineSpacing, sectionInsets等是针对布局所做的对应属性, 还有针对header, footer, items进行增删改查等的管理.

这些代码片段不代表 Section 全部的能力, 实际应用中缺啥补啥就行.

Level 7 对 Section 进行管理-CollectionProvider

同样, Section 也算是单个个体, 我们需要的也是一组或更多组 Section, 也要有增删改查等的处理.

我们看代码片段如下:

@interface CollectionProvider : NSObject

@property (nonatomic, readonly) NSInteger numberOfSections;
- (void)addSectionWithBlock:(void(^NS_NOESCAPE)(__kindof Section *make))block;
- (NSInteger)addSection:(Section *)section;
- (nullable NSIndexSet *)addSections:(NSArray<Section *> *)sections;
- (NSInteger)insertSection:(Section *)section atIndex:(NSInteger)index;
- (void)replaceSectionAtIndex:(NSInteger)index withSection:(Section *)section;

- (nullable __kindof Section *)sectionAtIndex:(NSInteger)index;
- (nullable __kindof Item *)itemAtIndexPath:(NSIndexPath *)indexPath;
 
- (nullable NSIndexPath *)moveItemAtIndexPath:(NSIndexPath *)sourceIndexPath toIndexPath:(NSIndexPath *)destinationIndexPath;
- (nullable NSIndexPath *)insertItem:(Item *)item atIndexPath:(NSIndexPath *)indexPath;

- (NSInteger)removeSectionAtIndex:(NSInteger)index;
- (NSInteger)removeSection:(Section *)section;
- (nullable NSIndexSet *)removeSections:(NSArray<Section *> *)sections;
- (void)removeAllSections;
  
- (nullable NSIndexPath *)removeItemAtIndexPath:(NSIndexPath *)indexPath;
- (nullable NSIndexPath *)removeItem:(Item *)item;
- (nullable NSArray<NSIndexPath *> *)removeItemsInArray:(NSArray<Item *> *)items;
- (nullable NSArray<NSIndexPath *> *)removeItemsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;
@end
复制代码

Provider 能够对 Section 进行管理, 接口中对应的有增删改查等功能. 看看这些接口, 有没有感觉它就像是 CollectionView 数据的提供方, 这也是起名 Provider 的原因.

通关-在 VC 中使用

到这里, 差不多可以通关了, 我们看一下在 VC 中的使用效果, 代码如下:

@interface ViewController()<UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) CollectionProvider *provider;
@end

@implementation ViewController
// ...
// ...
- (void)_setupSections {
    // 这里仅添加一个Section, 实际开发中可能会添加很多的 Section
    [_provider addSectionWithBlock:^(__kindof Section * _Nonnull make) {
        make.minimumLineSpacing = 8;
        make.minimumInteritemSpacing = 8;
        make.sectionInsets = UIEdgeInsetsMake(12, 12, 12, 12);
        
        NSArray *models = nil; // 将要添加的数据
        for ( id model in models ) {
            DemoItem *item = [DemoItem.alloc initWithModel:model];
            item.selectionHandler = ^(__kindof Item * _Nonnull item, NSIndexPath * _Nonnull indexPath) {
                // ...
                // ...
            };
            item.likingHandler = ^(DemoItem *item) {
                // ...
                // ...
            }
            [make addItem:item];
        }
    }];
}

- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
    return _provider.numberOfSections;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
    return [_provider sectionAtIndex:section].numberOfItems;
}

- (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath {
    [[_provider itemAtIndexPath:indexPath] bindCell:cell atIndexPath:indexPath];
}

- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath {
    [[_provider itemAtIndexPath:indexPath] unbindCell:cell atIndexPath:indexPath];
}
 
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    Item *item = [_provider itemAtIndexPath:indexPath];
    if ( item.selectionHandler != nil ) item.selectionHandler(item, indexPath);
}

- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section {
    return [_provider sectionAtIndex:section].minimumLineSpacing;
}

- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section {
    return [_provider sectionAtIndex:section].minimumInteritemSpacing;
}

- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
    return [_provider sectionAtIndex:section].contentInsets;
}

#pragma mark **-**

- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    return nil; // 最后两坑--1
}

- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return CGSizeZero; // 最后两坑--2
}
@end
复制代码

哈哈, 最后还有两个坑没填呢.

我们先填一下collectionView:cellForItemAtIndexPath:, 之前一直没说 Cell 注册的时机, 其实就是这里, 这个地方就是我们注册的最佳时机. 这个方法需要返回 Cell, 所以我们把注册和返回 Cell 的工作一起完成. 代码片段如下:

// -------------- Register.h --------------
@interface CollectionRegister : NSObject
// ...
// ...
// 根据 Item 的注册信息, 注册并返回 Cell
- (nullable __kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView 
                               dequeueReusableCellWithItem:(Item *)item forIndexPath:(NSIndexPath *)indexPath;
@end

// -------------- ViewController.m --------------
@interface ViewController()<UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
// ...
// ...
@property (nonatomic, strong) CollectionRegister *collectionRegister;
@end

@implementation ViewController
// ...
// ...
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    // 注册并返回 Cell
    return [_collectionRegister collectionView:collectionView dequeueReusableCellWithItem:[_provider itemAtIndexPath:indexPath] forIndexPath:indexPath]; 
}
@end
复制代码

如上使用 CollectionRegister 进行注册和返回 Cell, 你可能会想是否有重复注册的问题, 哈哈, 这取决于你对这个方法的具体实现, 反正接口就那样了(甩锅).

接下来是最后一个坑collectionView:layout:sizeForItemAtIndexPath:, 通过上面的做法你应该会想到我可能又要甩坑了, 代码如下:

// -------------- CollectionSizes.h --------------
@interface CollectionSizes : NSObject
// ...
// ...
- (CGSize)collectionView:(UICollectionView *)collectionView
                  layout:(UICollectionViewLayout *)layout
             sizeForItem:(CollectionItem *)item
               inSection:(Section *)section
             atIndexPath:(NSIndexPath *)indexPath;
@end

// -------------- ViewController.m --------------
@interface ViewController()<UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
// ...
// ...
@property (nonatomic, strong) CollectionSizes *collectionSizes;
@end

@implementation ViewController
// ...
// ...
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return [_collectionSizes collectionView:collectionView layout:collectionViewLayout sizeForItem:[_provider itemAtIndexPath:indexPath] inSection:[_provider sectionAtIndex:indexPath.section] atIndexPath:indexPath];
}
@end
复制代码

如上使用 CollectionSizes 计算和返回 size, 你可能会想到是否有重复计算的问题, 哈哈, 这取决于你对这个方法的具体实现, 反正接口就那样了(再次甩锅).

通关完毕.

留个 demo: github.com/changsanjia…

分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改