IOS过滤全局的Emoji表情输入
博客专区 > xoHome 的博客 > 博客详情
IOS过滤全局的Emoji表情输入
xoHome 发表于1年前
IOS过滤全局的Emoji表情输入
  • 发表于 1年前
  • 阅读 802
  • 收藏 4
  • 点赞 3
  • 评论 14

330元/年抢阿里云香港云服务器,节省80%出海成本 >>>   

摘要: 很久没写博客了,不是因为懒,而是我个人比较喜欢写偏向底层偏向原理性质的东西,可工作中,很少遇到需要探究底层的问题。 最近项目遇到了一个坑,Server端数据库做迁移发现新环境的配置编码不支持Emoji存储,且库是共用的,不能再修改编码结构,这对于一款已经上线且不能大动的APP来说,如何最小代价修复BUG成了当务之急的问题。

如摘要描述的上下文,现在需要解决Emoji输入导致的数据请求Error的问题。

问题原因:

在Unicode编码中,Emoji字符最小2个字节,小部分3个字节,更多4个字节,更丧心病狂的是有些输入法(比如某狗),存在11个字节的Emoji。而我们后端使用的数据库是MySql,默认使用Utf8编码,而默认的Utf8编码对字符的理解就是三个字节,所以当超过三个字节的Emoji入库时,就会报错执行失败(Incorrect string value '\xx\xx\xx\xx' for column 'field' at row 1),简单来说就是放不下,这问题其实修改MySql编码为Utf8mb4可以解决,但数据库改编码这种事情,是很敏感的,反正各种原因下,这问题得客户端解决。

需求分析:

我们的APP是一款汽车工具类APP,所提交的数据都是严谨且有意义的,而Emoji这种数据对我们来说可以说是可有可无。之前的做法是不做过滤不做限制(除格式要求区域外),现在既然遇到了问题,那索性的,将Emoji输入全部过滤掉。

方案分析:

需求已定,那现在的问题就有两个,一是如何全局过滤UITextField的输入(UITextView之类的我们没用),二是Emoji表情如何去过滤。这俩问题我们分开的讨论。

问题追究:

一、全局拦截UITextField输入

方案一:UITextFiled输入的信息最终都走网络接口发往服务器,基于这个,可以在网络层对所有Params做过滤,可保证到达服务器的数据不包含Emoji信息。但仅仅是解决了服务器报错问题,对客户端的体验很糟糕(明明可以输入Emoji,可数据拉取之后却丢失),另外,网络包是个公共的区域,它提供全局甚至多APP服务,改这里带来的测试成本条件不允许,所以方案一否定。

方案二:因为这款APP的UI是纯代码编写,我们可以扩展一个EmojiTextField,在内部代理系统默认的Delegate,对Emoji在输入时做屏蔽,这样能提供最好的输入体验,而且输入的信息和数据库信息一致。可这个方案需要替换全局的UITextField,虽然Xcode能很容易做到这点,但做法略显粗暴,做待选方案。

方案三:Runtime,我一直认为OC的强大至少一半归功于Runtime,它能在运行时修改函数表,而"Category"特性又提供了函数的扩展和覆盖,这让方案三的实现成为可能。

具体实现,导入全局的Category,这里叫UITextField+Emoji,覆盖+load函数(该函数在当前类读入内存时会收到消息),在该函数中通过Runtime替换初始化和Delegate相关函数,在初始化函数中,我们代理业务代码设置的Delegate,当用户有输入操作时,会触发我们代理的Delegate,在处理完Emoji校验之后,再路由给业务代码的Delegate。

我们项目采用.pch做全局h导入,在这里我们导入UITextField+Emoji的Gategory

/// 过滤Emoji字符
#import "UITextField+EmojiText.h"

在.m中,我们在+load函数内替换相关函数指针,让原有函数指向我们实现的IMP

void exchangeMethod(Class class, SEL oSEL, SEL nSEL)
{
    Method oMethod = class_getInstanceMethod(class, oSEL);
    Method nMethod = class_getInstanceMethod(class, nSEL);
    
    // 验证当前实例是否实现originalSEL,避免返回父类SEL
    BOOL ok = class_addMethod(class, oSEL, method_getImplementation(nMethod), method_getTypeEncoding(nMethod));
    if (ok) {
        class_replaceMethod(class, nSEL, method_getImplementation(oMethod), method_getTypeEncoding(oMethod));
    } else {
        method_exchangeImplementations(oMethod, nMethod);
    }
}

+ (void) load
{
    // setDelegate,拦截Delegate设置,默认走Emoji过滤
    exchangeMethod([self class], @selector(setDelegate:), @selector(emoji_setDelegate:));
    // getDelegate,返回业务代码设置的Delegate,确保set和get统一
    exchangeMethod([self class], @selector(delegate), @selector(emoji_delegate));
    // 几种初始化情况
    exchangeMethod([self class], @selector(init), @selector(emoji_init));
    exchangeMethod([self class], @selector(initWithFrame:), @selector(emoji_initWithFrame:));
    exchangeMethod([self class], @selector(initWithCoder:), @selector(emoji_initWithCoder:));
    // 释放内部持有资源
    exchangeMethod([self class], @selector(dealloc), @selector(emoji_dealloc));
}

相关替换的函数实现

- (id) emoji_init
{
    id ret = [self emoji_init];
    
    // 因为执行了函数指针替换,setDelegate会走emoji_setDelegate,这里调用setDelegate是为了确保没有设置delegate的业务代码同样过滤Emoji
    self.delegate = nil;
    
    return ret;
}

- (id) emoji_initWithFrame:(CGRect)frame
{
    id ret = [self emoji_initWithFrame:frame];
    
    self.delegate = nil;
    
    return ret;
}

- (id) emoji_initWithCoder:(NSCoder *)aDecoder
{
    id ret = [self emoji_initWithCoder:aDecoder];
    
    self.delegate = nil;
    
    return ret;
}

- (void) emoji_setDelegate:(id<UITextFieldDelegate>)delegate
{
    // 如果没有设置过delegate,需要设置内部代理的Delegate,否则让替换内部originalDelegate
    id<UITextFieldDelegate> del = [self emoji_delegate];
    if (!del) {
        EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
        emojiDelegate.originalDelegate = delegate;
        [self emoji_setDelegate:emojiDelegate];
    } else {
        EmojiDelegate *emojiDelegate = (EmojiDelegate *) del;
        emojiDelegate.originalDelegate = delegate;
    }
}

- (id<UITextFieldDelegate>) emoji_delegate
{
    return ((EmojiDelegate *)[self emoji_delegate]).originalDelegate;
}

- (void) emoji_dealloc
{
    // EmojiDelegate默认是retain的,需要手动释放一次资源
    [[self emoji_delegate] release];
    [self emoji_setDelegate:nil];
    [self emoji_dealloc];
}

EmojiDelegate的具体实现,很简单很单纯的一个代理

@interface EmojiDelegate : NSObject<UITextFieldDelegate>
@property(nonatomic, weak) UITextField *textField;
@property(nonatomic, weak) id<UITextFieldDelegate> originalDelegate;

@property(nonatomic, strong) NSString *prevText;    // 上次的输入结果

- (id) initWithTextField:(UITextField *)textField;
@end

@implementation EmojiDelegate

- (id) initWithTextField:(UITextField *)textField
{
    self = [super init];
    
    self.textField = textField;
    [textField addTarget:self action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged];
    
    return self;
}

- (void) dealloc
{
    [_textField removeTarget:self action:@selector(textFieldDidChange:) forControlEvents:UIControlEventEditingChanged];
    self.originalDelegate = nil;
    self.prevText = nil;
    [super dealloc];
}

- (BOOL)textFieldShouldBeginEditing:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldShouldBeginEditing:)]) {
        return [self.originalDelegate textFieldShouldBeginEditing:textField];
    }
    return YES;
}

- (void)textFieldDidBeginEditing:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldDidBeginEditing:)]) {
        return [self.originalDelegate textFieldDidBeginEditing:textField];
    }
}

- (BOOL)textFieldShouldEndEditing:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldShouldEndEditing:)]) {
        return [self.originalDelegate textFieldShouldEndEditing:textField];
    }
    return YES;
}

- (void)textFieldDidEndEditing:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldDidEndEditing:)]) {
        return [self.originalDelegate textFieldDidEndEditing:textField];
    }
}

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
    if (string.length == 0) {
        return YES;
    }
    
    /// 过滤emoji
    
    // 忽略系统默认的emoji键盘
    if ([[[textField textInputMode] primaryLanguage] isEqualToString:@"emoji"]) {
        return NO;
    }
    
    // 验证string的emoji字符
    if ([string containEmoji]) {
        return NO;
    }
    
    if ([self.originalDelegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)]) {
        return [self.originalDelegate textField:textField shouldChangeCharactersInRange:range replacementString:string];
    }
    return YES;
}

- (BOOL)textFieldShouldClear:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldShouldClear:)]) {
        return [self.originalDelegate textFieldShouldClear:textField];
    }
    return NO;
}

- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    if ([self.originalDelegate respondsToSelector:@selector(textFieldShouldReturn:)]) {
        return [self.originalDelegate textFieldShouldReturn:textField];
    }
    return NO;
}

/**
 * 监听UITextField文本变动,规避中文输入法联想输入Emoji问题
 */
- (void) textFieldDidChange:(UITextField *)textField
{
    if ([textField markedTextRange] == nil) {
        NSString *text = textField.text;
        if ([text containEmoji]) {
            NSUInteger location = [textField selectedRange].location - 2;
            textField.text = _prevText;
            if (location > _prevText.length) {
                location = _prevText.length;
            }
            [textField setSelectedRange:NSMakeRange(location, 0)];
        } else {
            self.prevText = text;
        }
    }
}

这里重点在containEmoji函数,内部验证输入string是否包含Emoji元素。

至此,全局拦截问题解决,至于具体的Emoji过滤,接下来讨论。

 

二、字符串过滤Emoji

和大多数人一样,在解决这个问题时,百度谷歌bing一通搜索,找到了很多种解决方案,可实际效果都不尽人意,Emoji这问题比想象中要麻烦的多。

做之前做下简单扫盲,Emoji来源就不多说了,只要知道在某个版本的Unicode编码中加入了Emoji,并且不是放一块的,也就说在Unicode编码中,Emoji的地址没有规律可寻,那只能去硬匹配,可Emoji数量几百上千,这一个个去匹配实在太蠢了,咱得缩小匹配范围。

相信现在大家都用的UTF8编码,这是一种变长编码,提到变长,那肯定会有一个描述头,几个内容体,UTF8是一样的。

在一个字节中,如果第一个bit位是0,那么代表当前为单字节字符,0之后的7位bit为数据部分,代表在Unicode中的序号

对应的,如果第一位是1开头,代表是多字节字符,如果第二位是0,代表这个字节是多字节字符的数据字节,跟在头字节后面;如果前面有多个1,则几个1代表该字符有几个字节(包含当前字节),例如:

110xxxxx // 代表有两个字节,后面一定跟着一个10开头的数据字节
>>110xxxxx 10xxxxxx

1110xxxx // 代表有三个字节,后面跟着两个10开头的数据字节
>>1110xxxx 10xxxxxx 10xxxxxx

推理可知,Utf8中一个字符最长7个字节,其中数据位6个字节,其中Emoji在Unicode中分布在2、3、4、4+长度的地址中,其中长度为2的Emoji大部分是文字字符,这些我们可以放行,4、4+的Emoji可全部过滤,而我们可见文字基本都分部在3字节地址中,这里重点需要过滤3字节的Emoji(3字节的Emoji已经可以入库了,但为了统一体验,还是需要过滤掉),幸运的是3字节的Emoji不是很多,硬匹配也算说得过去。

根据从Unicode官网找到的资料,匹配三字节Unicode

- (BOOL) emojiInUnicode:(short)code
{
    if (code == 0x0023
        || code == 0x002A
        || (code >= 0x0030 && code <= 0x0039)
        || code == 0x00A9
        || code == 0x00AE
        || code == 0x203C
        || code == 0x2049
        || code == 0x2122
        || code == 0x2139
        || (code >= 0x2194 && code <= 0x2199)
        || code == 0x21A9 || code == 0x21AA
        || code == 0x231A || code == 0x231B
        || code == 0x2328
        || code == 0x23CF
        || (code >= 0x23E9 && code <= 0x23F3)
        || (code >= 0x23F8 && code <= 0x23FA)
        || code == 0x24C2
        || code == 0x25AA || code == 0x25AB
        || code == 0x25B6
        || code == 0x25C0
        || (code >= 0x25FB && code <= 0x25FE)
        || (code >= 0x2600 && code <= 0x2604)
        || code == 0x260E
        || code == 0x2611
        || code == 0x2614 || code == 0x2615
        || code == 0x2618
        || code == 0x261D
        || code == 0x2620
        || code == 0x2622 || code == 0x2623
        || code == 0x2626
        || code == 0x262A
        || code == 0x262E || code == 0x262F
        || (code >= 0x2638 && code <= 0x263A)
        || (code >= 0x2648 && code <= 0x2653)
        || code == 0x2660
        || code == 0x2663
        || code == 0x2665 || code == 0x2666
        || code == 0x2668
        || code == 0x267B
        || code == 0x267F
        || (code >= 0x2692 && code <= 0x2694)
        || code == 0x2696 || code == 0x2697
        || code == 0x2699
        || code == 0x269B || code == 0x269C
        || code == 0x26A0 || code == 0x26A1
        || code == 0x26AA || code == 0x26AB
        || code == 0x26B0 || code == 0x26B1
        || code == 0x26BD || code == 0x26BE
        || code == 0x26C4 || code == 0x26C5
        || code == 0x26C8
        || code == 0x26CE
        || code == 0x26CF
        || code == 0x26D1
        || code == 0x26D3 || code == 0x26D4
        || code == 0x26E9 || code == 0x26EA
        || (code >= 0x26F0 && code <= 0x26F5)
        || (code >= 0x26F7 && code <= 0x26FA)
        || code == 0x26FD
        || code == 0x2702
        || code == 0x2705
        || (code >= 0x2708 && code <= 0x270D)
        || code == 0x270F
        || code == 0x2712
        || code == 0x2714
        || code == 0x2716
        || code == 0x271D
        || code == 0x2721
        || code == 0x2728
        || code == 0x2733 || code == 0x2734
        || code == 0x2744
        || code == 0x2747
        || code == 0x274C
        || code == 0x274E
        || (code >= 0x2753 && code <= 0x2755)
        || code == 0x2757
        || code == 0x2763 || code == 0x2764
        || (code >= 0x2795 && code <= 0x2797)
        || code == 0x27A1
        || code == 0x27B0
        || code == 0x27BF
        || code == 0x2934 || code == 0x2935
        || (code >= 0x2B05 && code <= 0x2B07)
        || code == 0x2B1B || code == 0x2B1C
        || code == 0x2B50
        || code == 0x2B55
        || code == 0x3030
        || code == 0x303D
        || code == 0x3297
        || code == 0x3299
        // 第二段
        || code == 0x23F0) {
        return YES;
    }
    return NO;
}

另外还有很古老的一套Emoji,采用Unicode私有区域,现在基本没用了,不过还是过滤下

/**
 * 一种非官方的, 采用私有Unicode 区域
 * e0 - e5  01 - 59
 */
- (BOOL) emojiInSoftBankUnicode:(short)code
{
    return ((code >> 8) >= 0xE0 && (code >> 8) <= 0xE5 && (Byte)(code & 0xFF) < 0x60);
}

另外就是对输入string的过滤,需要过滤掉字节长度为非3的字符,然后校验3字节的unicode编码

- (BOOL) containEmoji
{
    NSUInteger len = [self lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
    if (len < 3) {  // 大于2个字符需要验证Emoji(有些Emoji仅三个字符)
        return NO;
    }
    
    // 仅考虑字节长度为3的字符,大于此范围的全部做Emoji处理
    NSData *data = [self dataUsingEncoding:NSUTF8StringEncoding];
    
    Byte *bts = (Byte *)[data bytes];
    Byte bt;
    short v;
    for (NSUInteger i = 0; i < len; i++) {
        bt = bts[i];
        
        if ((bt | 0x7F) == 0x7F) {  // 0xxxxxxx  ASIIC编码
            continue;
        }
        if ((bt | 0x1F) == 0xDF) {  // 110xxxxx  两个字节的字符
            i += 1;
            continue;
        }
        if ((bt | 0x0F) == 0xEF) {  // 1110xxxx  三个字节的字符(重点过滤项目)
            // 计算Unicode下标
            v = bt & 0x0F;
            v = v << 6;
            v |= bts[i + 1] & 0x3F;
            v = v << 6;
            v |= bts[i + 2] & 0x3F;
            
//            NSLog(@"%02X%02X", (Byte)(v >> 8), (Byte)(v & 0xFF));

            if ([self emojiInSoftBankUnicode:v] || [self emojiInUnicode:v]) {
                return YES;
            }
            
            i += 2;
            continue;
        }
        if ((bt | 0x3F) == 0xBF) {  // 10xxxxxx  10开头,为数据字节,直接过滤
            continue;
        }
        
        return YES;                 // 不是以上情况的字符全部超过三个字节,做Emoji处理
    }
    
    return NO;
}

然后将相关函数封装为NSString (Emoji)

完工。

 

相关参考资料:

http://tech.glowing.com/cn/method-swizzling-aop/

http://www.unicode.org/Public/emoji/3.0//emoji-data.txt

 

共有 人打赏支持
xoHome
粉丝 34
博文 7
码字总数 10746
评论 (14)
冰泪_
有demo ? 有的话可否发一份上来
xoHome

引用来自“冰泪_”的评论

有demo ? 有的话可否发一份上来
所有代码都在上面了,整合下就可以用了
Cocos543
很赞,网上太多例子只是简单过滤部分表情,楼主这个方法很规范~~~专门注册个帐号给你评论一下.
Cocos543
- (id) emoji_init
{
id ret = [self emoji_init];

// 因为执行了函数指针替换,setDelegate会走emoji_setDelegate,这里调用setDelegate是为了确保没有设置delegate的业务代码同样过滤Emoji
self.delegate = nil;

return ret;
}

这个不会死循环吗?
Cocos543
看清楚了, emoji_init和init交换了.所以不会死循环.
xoHome

引用来自“Cocos543”的评论

看清楚了, emoji_init和init交换了.所以不会死循环.
看来你也遇到了类似的问题啊,那个Emoji过滤你注意下,因为Unicode一直在更新,你得参考最新的文档,别漏掉了。
刘伟开源中国
我整合了代码怎么发现不调用EmojiDelegate的代码
刘伟开源中国
- (void)viewDidLoad {

[super viewDidLoad];
UITextField *textFiled = [[UITextField alloc] init];
textFiled.frame = CGRectMake(0, 100, 400, 40);
textFiled.center = self.view.center;
textFiled.delegate = self;
textFiled.backgroundColor = [UIColor redColor];
[self.view addSubview:textFiled];

}
刘伟开源中国
- (void) emoji_setDelegate:(id<UITextFieldDelegate>)delegate
{
// 如果没有设置过delegate,需要设置内部代理的Delegate,否则让替换内部originalDelegate
id<UITextFieldDelegate> del = [self emoji_delegate];
if (!del) {
EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
} else {
EmojiDelegate *emojiDelegate = (EmojiDelegate *) del;
emojiDelegate.originalDelegate = delegate;
}
}
这个一直只走这一块的代码 EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
xoHome

引用来自“刘伟开源中国”的评论

- (void) emoji_setDelegate:(id<UITextFieldDelegate>)delegate
{
// 如果没有设置过delegate,需要设置内部代理的Delegate,否则让替换内部originalDelegate
id<UITextFieldDelegate> del = [self emoji_delegate];
if (!del) {
EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
} else {
EmojiDelegate *emojiDelegate = (EmojiDelegate *) del;
emojiDelegate.originalDelegate = delegate;
}
}
这个一直只走这一块的代码 EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
这一步流程是正常的,EmojiDelegate代理了你原本的delegate,符合输入要求的会走到你设置的delegate中,不符合要求的会直接被EmojiDelegate拦截掉,输入Emoji在你原本的delegate中不会被调用。
你的问题是EmojiDelegate在输入过程中不被调用么?
走_马
楼主你好,请问这个分类是在MRC模式下的嘛
xoHome

引用来自“走_马”的评论

楼主你好,请问这个分类是在MRC模式下的嘛
是的 比较早了 在MRC模式下实现的。 ARC上一样能用,去掉ARC相关调用就好
Max_残影

引用来自“刘伟开源中国”的评论

- (void) emoji_setDelegate:(id<UITextFieldDelegate>)delegate
{
// 如果没有设置过delegate,需要设置内部代理的Delegate,否则让替换内部originalDelegate
id<UITextFieldDelegate> del = [self emoji_delegate];
if (!del) {
EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
} else {
EmojiDelegate *emojiDelegate = (EmojiDelegate *) del;
emojiDelegate.originalDelegate = delegate;
}
}
这个一直只走这一块的代码 EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];

引用来自“xoHome”的评论

这一步流程是正常的,EmojiDelegate代理了你原本的delegate,符合输入要求的会走到你设置的delegate中,不符合要求的会直接被EmojiDelegate拦截掉,输入Emoji在你原本的delegate中不会被调用。
你的问题是EmojiDelegate在输入过程中不被调用么?
楼主你好,EmojiDelegate在输入过程中不被调用是什么情况?
现在的情况是我自己设置的delegate 和 EmojiDelegate 都不会被调用
Max_残影

引用来自“刘伟开源中国”的评论

- (void) emoji_setDelegate:(id<UITextFieldDelegate>)delegate
{
// 如果没有设置过delegate,需要设置内部代理的Delegate,否则让替换内部originalDelegate
id<UITextFieldDelegate> del = [self emoji_delegate];
if (!del) {
EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];
} else {
EmojiDelegate *emojiDelegate = (EmojiDelegate *) del;
emojiDelegate.originalDelegate = delegate;
}
}
这个一直只走这一块的代码 EmojiDelegate *emojiDelegate = [[EmojiDelegate alloc] initWithTextField:self];
emojiDelegate.originalDelegate = delegate;
[self emoji_setDelegate:emojiDelegate];

引用来自“xoHome”的评论

这一步流程是正常的,EmojiDelegate代理了你原本的delegate,符合输入要求的会走到你设置的delegate中,不符合要求的会直接被EmojiDelegate拦截掉,输入Emoji在你原本的delegate中不会被调用。
你的问题是EmojiDelegate在输入过程中不被调用么?
问题已经解决了,之前我把文件改成ARC模式了,EmojiDelegate会被自动释放的缘故吧。改回MRC下就可了,感谢楼主无私奉献!
×
xoHome
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: