iOS使用UITableView实现的富文本编辑器

前言

公司最近做一个项目,其中有一个模块是富文本编辑模块,之前没做个类似的功能模块,本来以为这个功能很常见应该会有已经造好的轮子,或许我只要找到轮子,研究下轮子,然后修改打磨轮子,这件事就八九不离十了。不过,还是 too young to simple 了,有些事,还是得自己去面对的,或许这就叫做成长,感觉最近一年,对于编程这件事,更多了一点热爱,我感觉我不配过只会复制粘贴代码的人生,编程需要有挑战。所以,遇到困难,保持一份正念,路其实就在脚下,如果没有困难,那就制造困哪,迎难而上,人生没有白走的路,每一步都算数,毒鸡汤就到此为止,下面是干货了。

结果

实现的功能包含了:

  • 编辑器文字编辑
  • 编辑器图片编辑
  • 编辑器图文混排编辑
  • 编辑器图片上传,带有进度和失败提示,可以重新上传操作
  • 编辑器模型转换为HTML格式内容
  • 简单的本地数据存储和恢复编辑实现(草稿箱功能)
  • 配套的Java实现的服务器

后期有进行了性能的优化,可以看我的这篇文章: iOS使用Instrument-Time Profiler工具分析和优化性能问题

以及客户端代码开源托管地址:MMRichTextEdit
还有java实现的文件服务器代码开源托管地址:javawebserverdemo

没图没真相,下面是几张实现的效果图

Demo1
Demo1

Demo4
Demo4

Demo3
Demo3

Demo5草稿箱
Demo5草稿箱

调研分析

基本上有以下几种的实现方案:

  1. UITextView结合NSAttributeString实现图文混排编辑,这个方案可以在网上找到对应的开源代码,比如 SimpleWord 的实现就是使用这种方式,不过缺点是图片不能有交互,比如说在图片上添加进度条,添加上传失败提示,图片点击事件处理等等都不行,如果没有这种需求那么可以选择这种方案。
  2. 使用WebView通过js和原生的交互实现,比如 WordPress-EditorRichTextDemo ,主要的问题就是性能不够好,还有需要你懂得前端知识才能上手。
  3. 使用CoreText或者TextKit,这种也有实现方案的开源代码,比如说这个 YYText ,这个很有名气,不过他使用的图片插入编辑图片的位置是固定的,文字是围绕着图片,所以这种不符合我的要求,如果要使用这种方案,那修改的地方有很多,并且CoreText/TextKit使用是有一定的门槛的。
  4. 使用UITableView结合UITextView的假实现,主要的思路是每个Cell是一个文字输入的UITextView或者是用于显示图片使用的UITextView,图片显示之所以是选择UITextView是因为图片位置需要有输入光标,所以使用UITextView结合NSAttributeString的方式正好可以实现这个功能。图片和文字混排也就是显示图片的Cell和显示文字的Cell混排就可以实现了,主要的工作量是处理光标位置输入以及处理光标位置删除。

选型定型

前面三种方案都有了开源的实现,不过都不满足需要,只有第二种方案会比较接近一点,不过WebView结合JS的操作确实是性能不够好,内存占用也比较高, WordPress-EditorRichTextDemo ,这两种方法实现的编辑器会明显的感觉到不够流畅,并且离需要还有挺大的距离,所有没有选择在这基础上进行二次开发。第三种方案在网上有比较多的人推荐,不过我想他们大概也只是推荐而已,真正实现起来需要花费大把的时间,需要填的坑有很多,考虑到时间有限,以及项目的进度安排,这个坑我就没有去踩了。
我最终选择的是第四种方案,这种方案好的地方就是UITableView、UITextView都是十分熟悉的组件,使用组合的模式通过以上的分析,理论上是没有问题的,并且,UITableView有复用Cell的优势,所以时间性能和空间性能应该是不差的。

实现细节分析

使用UITableView集合UITextView的这种方案有很多细节需要注意

  1. Cell中添加UITextView,文字输入换行或者超过一行Cell高度自动伸缩处理
  2. Cell中添加UITextView显示图片的处理
  3. 光标处删除和添加图片的处理,换行的处理

需要解决问题,好的是有些是已经有人遇到并且解决的,其他的即使其他人没有遇到过,作为第一个吃螃蟹的人,我们详细的去分析下其实也不难

  1. 这个问题刚好有人遇到过,这里就直接发链接了iOS UITextView 输入内容实时更新cell的高度

实现上面效果的基本原理是:
1.在 cell 中设置好 text view 的 autolayout,让 cell 可以根据内容自适应大小
2.text view 中输入内容,根据内容更新 textView 的高度
3.调用 tableView 的 beginUpdates 和 endUpdates,重新计算 cell 的高度
4.将 text view 更新后的数据保存,以免 table view 滚动超过一屏再滚回来 text view 中的数据又不刷新成原来的数据了。

注意:上面文章中提到的思路是对的,不过在开发过程中遇到一个问题:使用自动布局计算高度的方式调用 tableView 的 beginUpdates 和 endUpdates,重新计算 cell 的高度会出现一个严重的BUG,textView中的文字会偏移导致不在正确的位置,所以实际的项目中禁用了tableView自动计算Cell高度的特性,采用手动计算Cell高度的方式,具体的可以看我的项目代码。

2.这个问题很简单,使用属性文字就行了,下面直接贴代码了
NSAttributedString结合NSTextAttachment就行了

/**
 显示图片的属性文字
 */
- (NSAttributedString*)attrStringWithContainerWidth:(NSInteger)containerWidth {
    if (!_attrString) {
        CGFloat showImageWidth = containerWidth - MMEditConfig.editAreaLeftPadding - MMEditConfig.editAreaRightPadding - MMEditConfig.imageDeltaWidth;
        NSTextAttachment *textAttachment = [[NSTextAttachment alloc] init];
        CGRect rect = CGRectZero;
        rect.size.width = showImageWidth;
        rect.size.height = showImageWidth * self.image.size.height / self.image.size.width;
        textAttachment.bounds = rect;
        textAttachment.image = self.image;
        
        NSAttributedString *attachmentString = [NSAttributedString attributedStringWithAttachment:textAttachment];
        NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:@""];
        [attributedString insertAttributedString:attachmentString atIndex:0];
        _attrString = attributedString;
        
        // 设置Size
        CGRect tmpImageFrame = rect;
        tmpImageFrame.size.height += MMEditConfig.editAreaTopPadding + MMEditConfig.editAreaBottomPadding;
        _imageFrame = tmpImageFrame;
    }
    return _attrString;
}

3.这个问题比较棘手,我自己也是先把可能的情况列出来,然后一个一个分支去处理这些情况,不难就是麻烦,下面的文本是我写在 备忘录 上的情况分析,- [x] 这种标识这种情况已经实现,- [ ] 这种标识暂时未实现,后面这部分会进行优化,主要的工作已经完成了,优化的工作量不会很大了。

UITableView实现的编辑器

return换行情况分析:
- [x] text节点:不处理  
- [x] Image节点-前面:上面是text,光标移动到上面一行,并且在最后添加一个换行,定位光标在最后将
- [x] Image节点-前面:上面是图片或者空,在上面添加一个Text节点,光标移动到上面一行,
- [x] Image节点-后面:下面是图片或者空,在下面添加一个Text节点,光标移动到下面一行,
- [x] Image节点-后面:下面是text,光标移动到下面一行,并且在最前面添加一个换行,定位光标在最前面

Delete情况分析:  
- [x] Text节点-当前的Text不为空-前面-:上面是图片,定位光标到上面图片的最后
- [x] Text节点-当前的Text不为空-前面-:上面是Text,合并当前Text和上面Text  这种情况不存在,在图片删除的时候进行合并
- [x] Text节点-当前的Text不为空-前面-:上面是空,不处理
- [x] Text节点-当前的Text为空-前面-没有其他元素(第一个)-:不处理
- [x] Text节点-当前的Text为空-前面-有其他元素-:删除这一行,定位光标到下面图片的最后
- [x] Text节点-当前的Text不为空-后面-:正常删除
- [x] Text节点-当前的Text为空-后面-:正常删除,和第三种情况:为空的情况处理一样

- [x] Image节点-前面-上面为Text(不为空)/Image定位到上面元素的后面
- [x] Image节点-前面-上面为Text(为空):删除上面Text节点
- [x] Image节点-前面-上面为空:不处理
- [ ] Image节点-后面-上面为空(第一个位置)-列表只有一个元素:添加一个Text节点,删除当前Image节点,光标放在添加的Text节点上 ****TODO:上面元素不处于显示区域不可定位****
- [x] Image节点-后面-上面为空(第一个位置)-列表多于一个元素:删除当前节点,光标放在后面元素之前
- [x] Image节点-后面-上面为图片:删除Image节点,定位到上面元素的后面
- [x] Image节点-后面-上面为Text-下面为图片或者空:删除Image节点,定位到上面元素的后面
- [x] Image节点-后面-上面为Text-下面为Text:删除Image节点,合并下面的Text到上面,删除下面Text节点,定位到上面元素的后面

图片节点添加文字的情况分析:
- [ ] 前面输入文字
- [ ] 后面输入文字

插入图片的情况分析:
- [x] activeIndex是Image节点-后面:下面添加一个图片节点
- [x] activeIndex是Image节点-前面:上面添加一个图片节点
- [x] activeIndex是Text节点:拆分光标前后内容插入一个图片节点和Text节点
- [x] 图片插入之后更新 activeIndexPath

基本上分析就到此为止了,talk is cheap, show me code,下面就是代码实现了。

代码实现

编辑模块

文字输入框的Cell实现

下面是文字输入框的Cell的主要代码,包含了

  1. 初始设置文字编辑Cell的高度、文字内容、是否显示Placeholder
  2. UITextViewDelegate 回调方法 textViewDidChange 中处理Cell的高度自动拉伸
  3. 删除的回调方法中处理前面删除和后面删除,删除回调的代理方法是继承 UITextView 重写 deleteBackward 方法进行的回调,具体的可以额查看 MMTextView 这个类的实现,很简单的一个实现。
@implementation MMRichTextCell
// ...
- (void)updateWithData:(id)data indexPath:(NSIndexPath*)indexPath {
    if ([data isKindOfClass:[MMRichTextModel class]]) {
        MMRichTextModel* textModel = (MMRichTextModel*)data;
        _textModel = textModel;
        
        // 重新设置TextView的约束
        [self.textView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.left.top.right.equalTo(self);
            make.bottom.equalTo(self).priority(900);
            make.height.equalTo(@(textModel.textFrame.size.height));
        }];
        // Content
        _textView.text = textModel.textContent;
        // Placeholder
        if (indexPath.row == 0) {
            self.textView.showPlaceHolder = YES;
        } else {
            self.textView.showPlaceHolder = NO;
        }
    }
}

- (void)beginEditing {
    [_textView becomeFirstResponder];
    
    if (![_textView.text isEqualToString:_textModel.textContent]) {
        _textView.text = _textModel.textContent;
        
        // 手动调用回调方法修改
        [self textViewDidChange:_textView];
    }
    
    if ([self curIndexPath].row == 0) {
        self.textView.showPlaceHolder = YES;
    } else {
        self.textView.showPlaceHolder = NO;
    }
}

# pragma mark - ......::::::: UITextViewDelegate :::::::......

- (void)textViewDidChange:(UITextView *)textView {
    CGRect frame = textView.frame;
    CGSize constraintSize = CGSizeMake(frame.size.width, MAXFLOAT);
    CGSize size = [textView sizeThatFits:constraintSize];
    
    // 更新模型数据
    _textModel.textFrame = CGRectMake(frame.origin.x, frame.origin.y, frame.size.width, size.height);
    _textModel.textContent = textView.text;
    _textModel.selectedRange = textView.selectedRange;
    _textModel.isEditing = YES;
    
    if (ABS(_textView.frame.size.height - size.height) > 5) {
        
        // 重新设置TextView的约束
        [self.textView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.left.top.right.equalTo(self);
            make.bottom.equalTo(self).priority(900);
            make.height.equalTo(@(_textModel.textFrame.size.height));
        }];
        
        UITableView* tableView = [self containerTableView];
        [tableView beginUpdates];
        [tableView endUpdates];
    }
}

- (BOOL)textViewShouldBeginEditing:(UITextView *)textView {
    textView.inputAccessoryView = [self.delegate mm_inputAccessoryView];
    if ([self.delegate respondsToSelector:@selector(mm_updateActiveIndexPath:)]) {
        [self.delegate mm_updateActiveIndexPath:[self curIndexPath]];
    }
    return YES;
}

- (BOOL)textViewShouldEndEditing:(UITextView *)textView {
    textView.inputAccessoryView = nil;
    return YES;
}

- (void)textViewDeleteBackward:(MMTextView *)textView {
    // 处理删除
    NSRange selRange = textView.selectedRange;
    if (selRange.location == 0) {
        if ([self.delegate respondsToSelector:@selector(mm_preDeleteItemAtIndexPath:)]) {
            [self.delegate mm_preDeleteItemAtIndexPath:[self curIndexPath]];
        }
    } else {
        if ([self.delegate respondsToSelector:@selector(mm_PostDeleteItemAtIndexPath:)]) {
            [self.delegate mm_PostDeleteItemAtIndexPath:[self curIndexPath]];
        }
    }
}

@end
显示图片Cell的实现

下面显示图片Cell的实现,主要包含了

  1. 初始设置文字编辑Cell的高度、图片显示内容
  2. UITextViewDelegate 回调方法 shouldChangeTextInRange 中处理换行和删除,这个地方的删除和Text编辑的Cell不一样,所以在这边做了特殊的处理,具体看一看 shouldChangeTextInRange 这个方法的处理方式。
  3. 处理图片上传的进度回调、失败回调、成功回调
@implementation MMRichImageCell
// 省略部否代码...
- (void)updateWithData:(id)data {
    if ([data isKindOfClass:[MMRichImageModel class]]) {
        MMRichImageModel* imageModel = (MMRichImageModel*)data;
        // 设置旧的数据delegate为nil
        _imageModel.uploadDelegate = nil;
        _imageModel = imageModel;
        // 设置新的数据delegate
        _imageModel.uploadDelegate = self;

        CGFloat width = [MMRichTextConfig sharedInstance].editAreaWidth;
        NSAttributedString* imgAttrStr = [_imageModel attrStringWithContainerWidth:width];
        _textView.attributedText = imgAttrStr;
        // 重新设置TextView的约束
        [self.textView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.left.top.right.equalTo(self);
            make.bottom.equalTo(self).priority(900);
            make.height.equalTo(@(imageModel.imageFrame.size.height));
        }];
        
        self.reloadButton.hidden = YES;
        
        // 根据上传的状态设置图片信息
        if (_imageModel.isDone) {
            self.progressView.hidden = NO;
            self.progressView.progress = _imageModel.uploadProgress;
            self.reloadButton.hidden = YES;
        }
        if (_imageModel.isFailed) {
            self.progressView.hidden = NO;
            self.progressView.progress = _imageModel.uploadProgress;
            self.reloadButton.hidden = NO;
        }
        if (_imageModel.uploadProgress > 0) {
            self.progressView.hidden = NO;
            self.progressView.progress = _imageModel.uploadProgress;
            self.reloadButton.hidden = YES;
        }
    }
}

#pragma mark - ......::::::: UITextViewDelegate :::::::......

- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text {
    // 处理换行
    if ([text isEqualToString:@"\n"]) {
        if (range.location == 0 && range.length == 0) {
            // 在前面添加换行
            if ([self.delegate respondsToSelector:@selector(mm_preInsertTextLineAtIndexPath:textContent:)]) {
                [self.delegate mm_preInsertTextLineAtIndexPath:[self curIndexPath]textContent:nil];
            }
        } else if (range.location == 1 && range.length == 0) {
            // 在后面添加换行
            if ([self.delegate respondsToSelector:@selector(mm_postInsertTextLineAtIndexPath:textContent:)]) {
                [self.delegate mm_postInsertTextLineAtIndexPath:[self curIndexPath] textContent:nil];
            }
        } else if (range.location == 0 && range.length == 2) {
            // 选中和换行
        }
    }
    
    // 处理删除
    if ([text isEqualToString:@""]) {
        NSRange selRange = textView.selectedRange;
        if (selRange.location == 0 && selRange.length == 0) {
            // 处理删除
            if ([self.delegate respondsToSelector:@selector(mm_preDeleteItemAtIndexPath:)]) {
                [self.delegate mm_preDeleteItemAtIndexPath:[self curIndexPath]];
            }
        } else if (selRange.location == 1 && selRange.length == 0) {
            // 处理删除
            if ([self.delegate respondsToSelector:@selector(mm_PostDeleteItemAtIndexPath:)]) {
                [self.delegate mm_PostDeleteItemAtIndexPath:[self curIndexPath]];
            }
        } else if (selRange.location == 0 && selRange.length == 2) {
            // 处理删除
            if ([self.delegate respondsToSelector:@selector(mm_preDeleteItemAtIndexPath:)]) {
                [self.delegate mm_preDeleteItemAtIndexPath:[self curIndexPath]];
            }
        }
    }
    return NO;
}

- (BOOL)textViewShouldBeginEditing:(UITextView *)textView {
    textView.inputAccessoryView = [self.delegate mm_inputAccessoryView];
    if ([self.delegate respondsToSelector:@selector(mm_updateActiveIndexPath:)]) {
        [self.delegate mm_updateActiveIndexPath:[self curIndexPath]];
    }
    return YES;
}

- (BOOL)textViewShouldEndEditing:(UITextView *)textView {
    textView.inputAccessoryView = nil;
    return YES;
}


#pragma mark - ......::::::: MMRichImageUploadDelegate :::::::......

// 上传进度回调
- (void)uploadProgress:(float)progress {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.progressView setProgress:progress];
    });
}

// 上传失败回调
- (void)uploadFail {
    [self.progressView setProgress:0.01f];
    self.reloadButton.hidden = NO;
}

// 上传完成回调
- (void)uploadDone {
    [self.progressView setProgress:1.0f];
}


@end

图片上传模块

图片上传模块中,上传的元素和上传回调抽象了对应的协议,图片上传模块是一个单利的管理类,管理进行中的上传元素和排队中的上传元素,

图片上传的元素和上传回调的抽象协议
@protocol UploadItemCallBackProtocal <NSObject>

- (void)mm_uploadProgress:(float)progress;
- (void)mm_uploadFailed;
- (void)mm_uploadDone:(NSString*)remoteImageUrlString;

@end

@protocol UploadItemProtocal <NSObject>

- (NSData*)mm_uploadData;
- (NSURL*)mm_uploadFileURL;

@end
图片上传的管理类

图片上传使用的是 NSURLSessionUploadTask 类处理

  1. completionHandler 回调中处理结果
  2. NSURLSessionDelegate 的方法 URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 中处理上传进度
  3. NSURLSessionDelegate 的方法 URLSession:task:didCompleteWithError: 中处理失败

上传管理类的关键代码如下:


@interface MMFileUploadUtil () <NSURLSessionDataDelegate, NSURLSessionDelegate, NSURLSessionTaskDelegate>
@property (strong,nonatomic) NSURLSession * session;
@property (nonatomic, strong) NSMutableArray* uploadingItems;
@property (nonatomic, strong) NSMutableDictionary* uploadingTaskIDToUploadItemMap;
@property (nonatomic, strong) NSMutableArray* todoItems;

@property (nonatomic, assign) NSInteger maxUploadTask;
@end

@implementation MMFileUploadUtil

- (void)addUploadItem:(id<UploadItemProtocal, UploadItemCallBackProtocal>)uploadItem {
    [self.todoItems addObject:uploadItem];
    [self startNextUploadTask];
}

- (void)startNextUploadTask {
    if (self.uploadingItems.count < _maxUploadTask) {
        // 添加下一个任务
        if (self.todoItems.count > 0) {
            id<UploadItemProtocal, UploadItemCallBackProtocal> uploadItem = self.todoItems.firstObject;
            [self.uploadingItems addObject:uploadItem];
            [self.todoItems removeObject:uploadItem];
            
            [self uploadItem:uploadItem];
        }
    }
}

- (void)uploadItem:(id<UploadItemProtocal, UploadItemCallBackProtocal>)uploadItem {
    NSMutableURLRequest * request = [self TSuploadTaskRequest];
    
    NSData* uploadData = [uploadItem mm_uploadData];
    NSData* totalData = [self TSuploadTaskRequestBody:uploadData];
    
    __block NSURLSessionUploadTask * uploadtask = nil;
    uploadtask = [self.session uploadTaskWithRequest:request fromData:totalData completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        NSString* result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"completionHandler  %@", result);
        
        NSString* imgUrlString = @"";
        NSError *JSONSerializationError;
        id obj = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&JSONSerializationError];
        if ([obj isKindOfClass:[NSDictionary class]]) {
            imgUrlString = [obj objectForKey:@"url"];
        }
        // 成功回调
        // FIXME: ZYT uploadtask ???
        id<UploadItemProtocal, UploadItemCallBackProtocal> uploadItem = [self.uploadingTaskIDToUploadItemMap objectForKey:@(uploadtask.taskIdentifier)];
        if (uploadItem) {
            if ([uploadItem respondsToSelector:@selector(mm_uploadDone:)]) {
                [uploadItem mm_uploadDone:imgUrlString];
            }
            [self.uploadingTaskIDToUploadItemMap removeObjectForKey:@(uploadtask.taskIdentifier)];
            [self.uploadingItems removeObject:uploadItem];
        }
        
        [self startNextUploadTask];
    }];
    [uploadtask resume];
    
    // 添加到映射中
    [self.uploadingTaskIDToUploadItemMap setObject:uploadItem forKey:@(uploadtask.taskIdentifier)];
}

#pragma mark - ......::::::: NSURLSessionDelegate :::::::......

-(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error{
    NSLog(@"didCompleteWithError = %@",error.description);
    
    // 失败回调
    if (error) {
        id<UploadItemProtocal, UploadItemCallBackProtocal> uploadItem = [self.uploadingTaskIDToUploadItemMap objectForKey:@(task.taskIdentifier)];
        if (uploadItem) {
            if ([uploadItem respondsToSelector:@selector(mm_uploadFailed)]) {
                [uploadItem mm_uploadFailed];
            }
            [self.uploadingTaskIDToUploadItemMap removeObjectForKey:@(task.taskIdentifier)];
            [self.uploadingItems removeObject:uploadItem];
        }
    }
    
    [self startNextUploadTask];
}

-(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didSendBodyData:(int64_t)bytesSent totalBytesSent:(int64_t)totalBytesSent totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend{
    NSLog(@"bytesSent:%@-totalBytesSent:%@-totalBytesExpectedToSend:%@", @(bytesSent), @(totalBytesSent), @(totalBytesExpectedToSend));
    
    // 进度回调
    id<UploadItemProtocal, UploadItemCallBackProtocal> uploadItem = [self.uploadingTaskIDToUploadItemMap objectForKey:@(task.taskIdentifier)];
    if ([uploadItem respondsToSelector:@selector(mm_uploadProgress:)]) {
        [uploadItem mm_uploadProgress:(totalBytesSent * 1.0f/totalBytesExpectedToSend)];
    }
}

@end

图片上传的回调会通过 UploadItemCallBackProtocal 协议的实现方法回调到图片编辑的模型中,更新对应的数据。图片编辑的数据模型是 MMRichImageModel ,该模型实现了 UploadItemProtocalUploadItemCallBackProtocal 协议,实现 UploadItemCallBackProtocal 的方法更新数据模型的同时,会通过delegate通知到Cell更新进度和失败成功的状态。
关键的实现如下

@implementation MMRichImageModel

- (void)setUploadProgress:(float)uploadProgress {
    _uploadProgress = uploadProgress;
    if ([_uploadDelegate respondsToSelector:@selector(uploadProgress:)]) {
        [_uploadDelegate uploadProgress:uploadProgress];
    }
}

- (void)setIsDone:(BOOL)isDone {
    _isDone = isDone;
    if ([_uploadDelegate respondsToSelector:@selector(uploadDone)]) {
        [_uploadDelegate uploadDone];
    }
}

- (void)setIsFailed:(BOOL)isFailed {
    _isFailed = isFailed;
    if ([_uploadDelegate respondsToSelector:@selector(uploadFail)]) {
        [_uploadDelegate uploadFail];
    }
}


#pragma mark - ......::::::: UploadItemCallBackProtocal :::::::......
- (void)mm_uploadProgress:(float)progress {
    self.uploadProgress = progress;
}

- (void)mm_uploadFailed {
    self.isFailed = YES;
}

- (void)mm_uploadDone:(NSString *)remoteImageUrlString {
    self.remoteImageUrlString = remoteImageUrlString;
    self.isDone = YES;
}


#pragma mark - ......::::::: UploadItemProtocal :::::::......
- (NSData*)mm_uploadData {
    return UIImageJPEGRepresentation(_image, 0.6);
}

- (NSURL*)mm_uploadFileURL {
    return nil;
}

@end

内容处理模块

最终是要把内容序列化然后上传到服务端的,我们的序列化方案是转换为HTML,内容处理模块主要包含了以下几点:

  • 生成HTML格式的内容
  • 验证内容是否有效,判断图片时候全部上传成功
  • 压缩图片
  • 保存图片到本地

这部分收尾的工作比较的简单,下面是实现代码:

#define kRichContentEditCache      @"RichContentEditCache"


@implementation MMRichContentUtil

+ (NSString*)htmlContentFromRichContents:(NSArray*)richContents {
    NSMutableString *htmlContent = [NSMutableString string];

    for (int i = 0; i< richContents.count; i++) {
        NSObject* content = richContents[i];
        if ([content isKindOfClass:[MMRichImageModel class]]) {
            MMRichImageModel* imgContent = (MMRichImageModel*)content;
            [htmlContent appendString:[NSString stringWithFormat:@"<img src=\"%@\" width=\"%@\" height=\"%@\" />", imgContent.remoteImageUrlString, @(imgContent.image.size.width), @(imgContent.image.size.height)]];
        } else if ([content isKindOfClass:[MMRichTextModel class]]) {
            MMRichTextModel* textContent = (MMRichTextModel*)content;
            [htmlContent appendString:textContent.textContent];
        }
        
        // 添加换行
        if (i != richContents.count - 1) {
            [htmlContent appendString:@"<br />"];
        }
    }
    
    return htmlContent;
}

+ (BOOL)validateRichContents:(NSArray*)richContents {
    for (int i = 0; i< richContents.count; i++) {
        NSObject* content = richContents[i];
        if ([content isKindOfClass:[MMRichImageModel class]]) {
            MMRichImageModel* imgContent = (MMRichImageModel*)content;
            if (imgContent.isDone == NO) {
                return NO;
            }
        }
    }
    return YES;
}

+ (UIImage*)scaleImage:(UIImage*)originalImage {
    float scaledWidth = 1242;
    return [originalImage scaletoSize:scaledWidth];
}

+ (NSString*)saveImageToLocal:(UIImage*)image {
    NSString *path=[self createDirectory:kRichContentEditCache];
    NSData* data = UIImageJPEGRepresentation(image, 1.0);
    NSString *filePath = [path stringByAppendingPathComponent:[self.class genRandomFileName]];
    [data writeToFile:filePath atomically:YES];
    return filePath;
}

// 创建文件夹
+ (NSString *)createDirectory:(NSString *)path {
    BOOL isDir = NO;
    NSString *finalPath = [CACHE_PATH stringByAppendingPathComponent:path];
    
    if (!([[NSFileManager defaultManager] fileExistsAtPath:finalPath
                                               isDirectory:&isDir]
          && isDir))
    {
        [[NSFileManager defaultManager] createDirectoryAtPath:finalPath
                                 withIntermediateDirectories :YES
                                                  attributes :nil
                                                       error :nil];
    }
    
    return finalPath;
}

+ (NSString*)genRandomFileName {
    NSTimeInterval timeStamp = [[NSDate date] timeIntervalSince1970];
    uint32_t random = arc4random_uniform(10000);
    return [NSString stringWithFormat:@"%@-%@.png", @(timeStamp), @(random)];
}

@end

总结

这个功能从选型定型到实现大概花费了3天的时间,因为时间原因,有很多地方优化的不到位,如果看官有建议意见希望给我留言,我会继续完善,或者你有时间欢迎加入这个项目,可以一起做得更好,代码开源看下面的链接。

代码托管位置

客户端代码开源托管地址:MMRichTextEdit
java实现的文件服务器代码开源托管地址:javawebserverdemo

参考链接

iOS UITextView 输入内容实时更新cell的高度
如何实现移动端的图文混排编辑功能?
JavaWeb实现文件上传下载功能实例解析
使用NSURLSessionUploadTask完成上传文件

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,529评论 5 475
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,015评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,409评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,385评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,387评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,466评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,880评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,528评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,727评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,528评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,602评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,302评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,873评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,890评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,132评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,777评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,310评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,010评论 4 62
  • 我再也见不到他了。内心隐隐有火山要爆发。 就在五分钟前,一个戴着口罩的男子找到我,见我独自一人坐在操场上仰望星空,...
    凌天夜阅读 304评论 0 2
  • 静女其姝,俟我于城隅。爱而不见,搔首踟蹰。静女其娈,贻我彤管。彤管有炜,说怿女美。自牧归荑,洵美且异。匪女之为美,...
    濠濮之乐阅读 381评论 0 4
  • 泉眼清凌凌的媚脸 澄澈,温柔的可以掸去疼痛 我的眼珠有鼓出岁月的鱼尾纹 与碧幽泉水的小白浪交融 一次次,有人失足掉...
    938377db52e3阅读 202评论 9 1