iOS使用UITableView實(shí)現(xiàn)的富文本編輯器

前言

公司最近做一個(gè)項(xiàng)目,其中有一個(gè)模塊是富文本編輯模塊恕出,之前沒(méi)做個(gè)類(lèi)似的功能模塊,本來(lái)以為這個(gè)功能很常見(jiàn)應(yīng)該會(huì)有已經(jīng)造好的輪子筷登,或許我只要找到輪子剃根,研究下輪子哩盲,然后修改打磨輪子前方,這件事就八九不離十了狈醉。不過(guò),還是 too young to simple 了惠险,有些事苗傅,還是得自己去面對(duì)的,或許這就叫做成長(zhǎng)班巩,感覺(jué)最近一年渣慕,對(duì)于編程這件事,更多了一點(diǎn)熱愛(ài)抱慌,我感覺(jué)我不配過(guò)只會(huì)復(fù)制粘貼代碼的人生逊桦,編程需要有挑戰(zhàn)。所以抑进,遇到困難强经,保持一份正念,路其實(shí)就在腳下寺渗,如果沒(méi)有困難匿情,那就制造困哪,迎難而上信殊,人生沒(méi)有白走的路炬称,每一步都算數(shù),毒雞湯就到此為止涡拘,下面是干貨了玲躯。

結(jié)果

實(shí)現(xiàn)的功能包含了:

  • 編輯器文字編輯
  • 編輯器圖片編輯
  • 編輯器圖文混排編輯
  • 編輯器圖片上傳,帶有進(jìn)度和失敗提示鳄乏,可以重新上傳操作
  • 編輯器模型轉(zhuǎn)換為HTML格式內(nèi)容
  • 簡(jiǎn)單的本地?cái)?shù)據(jù)存儲(chǔ)和恢復(fù)編輯實(shí)現(xiàn)(草稿箱功能)
  • 配套的Java實(shí)現(xiàn)的服務(wù)器

后期有進(jìn)行了性能的優(yōu)化府蔗,可以看我的這篇文章: iOS使用Instrument-Time Profiler工具分析和優(yōu)化性能問(wèn)題

以及客戶(hù)端代碼開(kāi)源托管地址:MMRichTextEdit
還有java實(shí)現(xiàn)的文件服務(wù)器代碼開(kāi)源托管地址:javawebserverdemo

沒(méi)圖沒(méi)真相,下面是幾張實(shí)現(xiàn)的效果圖

Demo1
Demo1

Demo4
Demo4

Demo3
Demo3

Demo5草稿箱
Demo5草稿箱

調(diào)研分析

基本上有以下幾種的實(shí)現(xiàn)方案:

  1. UITextView結(jié)合NSAttributeString實(shí)現(xiàn)圖文混排編輯汞窗,這個(gè)方案可以在網(wǎng)上找到對(duì)應(yīng)的開(kāi)源代碼姓赤,比如 SimpleWord 的實(shí)現(xiàn)就是使用這種方式,不過(guò)缺點(diǎn)是圖片不能有交互仲吏,比如說(shuō)在圖片上添加進(jìn)度條不铆,添加上傳失敗提示,圖片點(diǎn)擊事件處理等等都不行裹唆,如果沒(méi)有這種需求那么可以選擇這種方案誓斥。
  2. 使用WebView通過(guò)js和原生的交互實(shí)現(xiàn),比如 WordPress-Editor 许帐、RichTextDemo 劳坑,主要的問(wèn)題就是性能不夠好,還有需要你懂得前端知識(shí)才能上手成畦。
  3. 使用CoreText或者TextKit距芬,這種也有實(shí)現(xiàn)方案的開(kāi)源代碼涝开,比如說(shuō)這個(gè) YYText ,這個(gè)很有名氣框仔,不過(guò)他使用的圖片插入編輯圖片的位置是固定的舀武,文字是圍繞著圖片,所以這種不符合我的要求离斩,如果要使用這種方案银舱,那修改的地方有很多,并且CoreText/TextKit使用是有一定的門(mén)檻的跛梗。
  4. 使用UITableView結(jié)合UITextView的假實(shí)現(xiàn)寻馏,主要的思路是每個(gè)Cell是一個(gè)文字輸入的UITextView或者是用于顯示圖片使用的UITextView,圖片顯示之所以是選擇UITextView是因?yàn)閳D片位置需要有輸入光標(biāo)核偿,所以使用UITextView結(jié)合NSAttributeString的方式正好可以實(shí)現(xiàn)這個(gè)功能操软。圖片和文字混排也就是顯示圖片的Cell和顯示文字的Cell混排就可以實(shí)現(xiàn)了,主要的工作量是處理光標(biāo)位置輸入以及處理光標(biāo)位置刪除宪祥。

選型定型

前面三種方案都有了開(kāi)源的實(shí)現(xiàn)聂薪,不過(guò)都不滿足需要,只有第二種方案會(huì)比較接近一點(diǎn)蝗羊,不過(guò)WebView結(jié)合JS的操作確實(shí)是性能不夠好藏澳,內(nèi)存占用也比較高, WordPress-Editor 耀找、RichTextDemo 翔悠,這兩種方法實(shí)現(xiàn)的編輯器會(huì)明顯的感覺(jué)到不夠流暢,并且離需要還有挺大的距離野芒,所有沒(méi)有選擇在這基礎(chǔ)上進(jìn)行二次開(kāi)發(fā)蓄愁。第三種方案在網(wǎng)上有比較多的人推薦,不過(guò)我想他們大概也只是推薦而已狞悲,真正實(shí)現(xiàn)起來(lái)需要花費(fèi)大把的時(shí)間撮抓,需要填的坑有很多,考慮到時(shí)間有限摇锋,以及項(xiàng)目的進(jìn)度安排丹拯,這個(gè)坑我就沒(méi)有去踩了。
我最終選擇的是第四種方案荸恕,這種方案好的地方就是UITableView乖酬、UITextView都是十分熟悉的組件,使用組合的模式通過(guò)以上的分析融求,理論上是沒(méi)有問(wèn)題的咬像,并且,UITableView有復(fù)用Cell的優(yōu)勢(shì),所以時(shí)間性能和空間性能應(yīng)該是不差的县昂。

實(shí)現(xiàn)細(xì)節(jié)分析

使用UITableView集合UITextView的這種方案有很多細(xì)節(jié)需要注意

  1. Cell中添加UITextView肮柜,文字輸入換行或者超過(guò)一行Cell高度自動(dòng)伸縮處理
  2. Cell中添加UITextView顯示圖片的處理
  3. 光標(biāo)處刪除和添加圖片的處理,換行的處理

需要解決問(wèn)題七芭,好的是有些是已經(jīng)有人遇到并且解決的,其他的即使其他人沒(méi)有遇到過(guò)蔑赘,作為第一個(gè)吃螃蟹的人狸驳,我們?cè)敿?xì)的去分析下其實(shí)也不難

  1. 這個(gè)問(wèn)題剛好有人遇到過(guò),這里就直接發(fā)鏈接了iOS UITextView 輸入內(nèi)容實(shí)時(shí)更新cell的高度

實(shí)現(xiàn)上面效果的基本原理是:
1.在 cell 中設(shè)置好 text view 的 autolayout缩赛,讓 cell 可以根據(jù)內(nèi)容自適應(yīng)大小
2.text view 中輸入內(nèi)容耙箍,根據(jù)內(nèi)容更新 textView 的高度
3.調(diào)用 tableView 的 beginUpdates 和 endUpdates,重新計(jì)算 cell 的高度
4.將 text view 更新后的數(shù)據(jù)保存酥馍,以免 table view 滾動(dòng)超過(guò)一屏再滾回來(lái) text view 中的數(shù)據(jù)又不刷新成原來(lái)的數(shù)據(jù)了辩昆。

注意:上面文章中提到的思路是對(duì)的,不過(guò)在開(kāi)發(fā)過(guò)程中遇到一個(gè)問(wèn)題:使用自動(dòng)布局計(jì)算高度的方式調(diào)用 tableView 的 beginUpdates 和 endUpdates旨袒,重新計(jì)算 cell 的高度會(huì)出現(xiàn)一個(gè)嚴(yán)重的BUG汁针,textView中的文字會(huì)偏移導(dǎo)致不在正確的位置,所以實(shí)際的項(xiàng)目中禁用了tableView自動(dòng)計(jì)算Cell高度的特性砚尽,采用手動(dòng)計(jì)算Cell高度的方式施无,具體的可以看我的項(xiàng)目代碼。

2.這個(gè)問(wèn)題很簡(jiǎn)單必孤,使用屬性文字就行了猾骡,下面直接貼代碼了
NSAttributedString結(jié)合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;
        
        // 設(shè)置Size
        CGRect tmpImageFrame = rect;
        tmpImageFrame.size.height += MMEditConfig.editAreaTopPadding + MMEditConfig.editAreaBottomPadding;
        _imageFrame = tmpImageFrame;
    }
    return _attrString;
}

3.這個(gè)問(wèn)題比較棘手,我自己也是先把可能的情況列出來(lái)敷搪,然后一個(gè)一個(gè)分支去處理這些情況兴想,不難就是麻煩,下面的文本是我寫(xiě)在 備忘錄 上的情況分析赡勘,- [x] 這種標(biāo)識(shí)這種情況已經(jīng)實(shí)現(xiàn)嫂便,- [ ] 這種標(biāo)識(shí)暫時(shí)未實(shí)現(xiàn),后面這部分會(huì)進(jìn)行優(yōu)化闸与,主要的工作已經(jīng)完成了顽悼,優(yōu)化的工作量不會(huì)很大了。

UITableView實(shí)現(xiàn)的編輯器

return換行情況分析:
- [x] text節(jié)點(diǎn):不處理  
- [x] Image節(jié)點(diǎn)-前面:上面是text几迄,光標(biāo)移動(dòng)到上面一行蔚龙,并且在最后添加一個(gè)換行,定位光標(biāo)在最后將
- [x] Image節(jié)點(diǎn)-前面:上面是圖片或者空映胁,在上面添加一個(gè)Text節(jié)點(diǎn)木羹,光標(biāo)移動(dòng)到上面一行,
- [x] Image節(jié)點(diǎn)-后面:下面是圖片或者空,在下面添加一個(gè)Text節(jié)點(diǎn)坑填,光標(biāo)移動(dòng)到下面一行抛人,
- [x] Image節(jié)點(diǎn)-后面:下面是text,光標(biāo)移動(dòng)到下面一行脐瑰,并且在最前面添加一個(gè)換行妖枚,定位光標(biāo)在最前面

Delete情況分析:  
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text不為空-前面-:上面是圖片,定位光標(biāo)到上面圖片的最后
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text不為空-前面-:上面是Text苍在,合并當(dāng)前Text和上面Text  這種情況不存在绝页,在圖片刪除的時(shí)候進(jìn)行合并
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text不為空-前面-:上面是空,不處理
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text為空-前面-沒(méi)有其他元素(第一個(gè))-:不處理
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text為空-前面-有其他元素-:刪除這一行寂恬,定位光標(biāo)到下面圖片的最后
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text不為空-后面-:正常刪除
- [x] Text節(jié)點(diǎn)-當(dāng)前的Text為空-后面-:正常刪除续誉,和第三種情況:為空的情況處理一樣

- [x] Image節(jié)點(diǎn)-前面-上面為T(mén)ext(不為空)/Image定位到上面元素的后面
- [x] Image節(jié)點(diǎn)-前面-上面為T(mén)ext(為空):刪除上面Text節(jié)點(diǎn)
- [x] Image節(jié)點(diǎn)-前面-上面為空:不處理
- [ ] Image節(jié)點(diǎn)-后面-上面為空(第一個(gè)位置)-列表只有一個(gè)元素:添加一個(gè)Text節(jié)點(diǎn),刪除當(dāng)前Image節(jié)點(diǎn)初肉,光標(biāo)放在添加的Text節(jié)點(diǎn)上 ****TODO:上面元素不處于顯示區(qū)域不可定位****
- [x] Image節(jié)點(diǎn)-后面-上面為空(第一個(gè)位置)-列表多于一個(gè)元素:刪除當(dāng)前節(jié)點(diǎn)酷鸦,光標(biāo)放在后面元素之前
- [x] Image節(jié)點(diǎn)-后面-上面為圖片:刪除Image節(jié)點(diǎn),定位到上面元素的后面
- [x] Image節(jié)點(diǎn)-后面-上面為T(mén)ext-下面為圖片或者空:刪除Image節(jié)點(diǎn)牙咏,定位到上面元素的后面
- [x] Image節(jié)點(diǎn)-后面-上面為T(mén)ext-下面為T(mén)ext:刪除Image節(jié)點(diǎn)臼隔,合并下面的Text到上面,刪除下面Text節(jié)點(diǎn)妄壶,定位到上面元素的后面

圖片節(jié)點(diǎn)添加文字的情況分析:
- [ ] 前面輸入文字
- [ ] 后面輸入文字

插入圖片的情況分析:
- [x] activeIndex是Image節(jié)點(diǎn)-后面:下面添加一個(gè)圖片節(jié)點(diǎn)
- [x] activeIndex是Image節(jié)點(diǎn)-前面:上面添加一個(gè)圖片節(jié)點(diǎn)
- [x] activeIndex是Text節(jié)點(diǎn):拆分光標(biāo)前后內(nèi)容插入一個(gè)圖片節(jié)點(diǎn)和Text節(jié)點(diǎn)
- [x] 圖片插入之后更新 activeIndexPath

基本上分析就到此為止了躬翁,talk is cheap, show me code,下面就是代碼實(shí)現(xiàn)了盯拱。

代碼實(shí)現(xiàn)

編輯模塊

文字輸入框的Cell實(shí)現(xiàn)

下面是文字輸入框的Cell的主要代碼盒发,包含了

  1. 初始設(shè)置文字編輯Cell的高度、文字內(nèi)容狡逢、是否顯示Placeholder
  2. UITextViewDelegate 回調(diào)方法 textViewDidChange 中處理Cell的高度自動(dòng)拉伸
  3. 刪除的回調(diào)方法中處理前面刪除和后面刪除宁舰,刪除回調(diào)的代理方法是繼承 UITextView 重寫(xiě) deleteBackward 方法進(jìn)行的回調(diào),具體的可以額查看 MMTextView 這個(gè)類(lèi)的實(shí)現(xiàn)奢浑,很簡(jiǎn)單的一個(gè)實(shí)現(xiàn)蛮艰。
@implementation MMRichTextCell
// ...
- (void)updateWithData:(id)data indexPath:(NSIndexPath*)indexPath {
    if ([data isKindOfClass:[MMRichTextModel class]]) {
        MMRichTextModel* textModel = (MMRichTextModel*)data;
        _textModel = textModel;
        
        // 重新設(shè)置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;
        
        // 手動(dòng)調(diào)用回調(diào)方法修改
        [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];
    
    // 更新模型數(shù)據(jù)
    _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) {
        
        // 重新設(shè)置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的實(shí)現(xiàn)

下面顯示圖片Cell的實(shí)現(xiàn),主要包含了

  1. 初始設(shè)置文字編輯Cell的高度雀彼、圖片顯示內(nèi)容
  2. UITextViewDelegate 回調(diào)方法 shouldChangeTextInRange 中處理?yè)Q行和刪除壤蚜,這個(gè)地方的刪除和Text編輯的Cell不一樣,所以在這邊做了特殊的處理徊哑,具體看一看 shouldChangeTextInRange 這個(gè)方法的處理方式袜刷。
  3. 處理圖片上傳的進(jìn)度回調(diào)、失敗回調(diào)莺丑、成功回調(diào)
@implementation MMRichImageCell
// 省略部否代碼...
- (void)updateWithData:(id)data {
    if ([data isKindOfClass:[MMRichImageModel class]]) {
        MMRichImageModel* imageModel = (MMRichImageModel*)data;
        // 設(shè)置舊的數(shù)據(jù)delegate為nil
        _imageModel.uploadDelegate = nil;
        _imageModel = imageModel;
        // 設(shè)置新的數(shù)據(jù)delegate
        _imageModel.uploadDelegate = self;

        CGFloat width = [MMRichTextConfig sharedInstance].editAreaWidth;
        NSAttributedString* imgAttrStr = [_imageModel attrStringWithContainerWidth:width];
        _textView.attributedText = imgAttrStr;
        // 重新設(shè)置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;
        
        // 根據(jù)上傳的狀態(tài)設(shè)置圖片信息
        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 {
    // 處理?yè)Q行
    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 :::::::......

// 上傳進(jìn)度回調(diào)
- (void)uploadProgress:(float)progress {
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.progressView setProgress:progress];
    });
}

// 上傳失敗回調(diào)
- (void)uploadFail {
    [self.progressView setProgress:0.01f];
    self.reloadButton.hidden = NO;
}

// 上傳完成回調(diào)
- (void)uploadDone {
    [self.progressView setProgress:1.0f];
}


@end

圖片上傳模塊

圖片上傳模塊中著蟹,上傳的元素和上傳回調(diào)抽象了對(duì)應(yīng)的協(xié)議墩蔓,圖片上傳模塊是一個(gè)單利的管理類(lèi),管理進(jìn)行中的上傳元素和排隊(duì)中的上傳元素萧豆,

圖片上傳的元素和上傳回調(diào)的抽象協(xié)議
@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
圖片上傳的管理類(lèi)

圖片上傳使用的是 NSURLSessionUploadTask 類(lèi)處理

  1. completionHandler 回調(diào)中處理結(jié)果
  2. NSURLSessionDelegate 的方法 URLSession:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend: 中處理上傳進(jìn)度
  3. NSURLSessionDelegate 的方法 URLSession:task:didCompleteWithError: 中處理失敗

上傳管理類(lèi)的關(guān)鍵代碼如下:


@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) {
        // 添加下一個(gè)任務(wù)
        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"];
        }
        // 成功回調(diào)
        // 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);
    
    // 失敗回調(diào)
    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));
    
    // 進(jìn)度回調(diào)
    id<UploadItemProtocal, UploadItemCallBackProtocal> uploadItem = [self.uploadingTaskIDToUploadItemMap objectForKey:@(task.taskIdentifier)];
    if ([uploadItem respondsToSelector:@selector(mm_uploadProgress:)]) {
        [uploadItem mm_uploadProgress:(totalBytesSent * 1.0f/totalBytesExpectedToSend)];
    }
}

@end

圖片上傳的回調(diào)會(huì)通過(guò) UploadItemCallBackProtocal 協(xié)議的實(shí)現(xiàn)方法回調(diào)到圖片編輯的模型中阵面,更新對(duì)應(yīng)的數(shù)據(jù)。圖片編輯的數(shù)據(jù)模型是 MMRichImageModel 洪鸭,該模型實(shí)現(xiàn)了 UploadItemProtocalUploadItemCallBackProtocal 協(xié)議样刷,實(shí)現(xiàn) UploadItemCallBackProtocal 的方法更新數(shù)據(jù)模型的同時(shí),會(huì)通過(guò)delegate通知到Cell更新進(jìn)度和失敗成功的狀態(tài)卿嘲。
關(guān)鍵的實(shí)現(xiàn)如下

@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

內(nèi)容處理模塊

最終是要把內(nèi)容序列化然后上傳到服務(wù)端的颂斜,我們的序列化方案是轉(zhuǎn)換為HTML夫壁,內(nèi)容處理模塊主要包含了以下幾點(diǎn):

  • 生成HTML格式的內(nèi)容
  • 驗(yàn)證內(nèi)容是否有效拾枣,判斷圖片時(shí)候全部上傳成功
  • 壓縮圖片
  • 保存圖片到本地

這部分收尾的工作比較的簡(jiǎn)單,下面是實(shí)現(xiàn)代碼:

#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;
}

// 創(chuàng)建文件夾
+ (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

總結(jié)

這個(gè)功能從選型定型到實(shí)現(xiàn)大概花費(fèi)了3天的時(shí)間盒让,因?yàn)闀r(shí)間原因梅肤,有很多地方優(yōu)化的不到位,如果看官有建議意見(jiàn)希望給我留言邑茄,我會(huì)繼續(xù)完善姨蝴,或者你有時(shí)間歡迎加入這個(gè)項(xiàng)目,可以一起做得更好肺缕,代碼開(kāi)源看下面的鏈接左医。

代碼托管位置

客戶(hù)端代碼開(kāi)源托管地址:MMRichTextEdit
java實(shí)現(xiàn)的文件服務(wù)器代碼開(kāi)源托管地址:javawebserverdemo

參考鏈接

iOS UITextView 輸入內(nèi)容實(shí)時(shí)更新cell的高度
如何實(shí)現(xiàn)移動(dòng)端的圖文混排編輯功能?
JavaWeb實(shí)現(xiàn)文件上傳下載功能實(shí)例解析
使用NSURLSessionUploadTask完成上傳文件

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末同木,一起剝皮案震驚了整個(gè)濱河市浮梢,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌彤路,老刑警劉巖秕硝,帶你破解...
    沈念sama閱讀 218,755評(píng)論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異洲尊,居然都是意外死亡远豺,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,305評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén)坞嘀,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)躯护,“玉大人,你說(shuō)我怎么就攤上這事丽涩¢蛔觯” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,138評(píng)論 0 355
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)检眯。 經(jīng)常有香客問(wèn)我厘擂,道長(zhǎng),這世上最難降的妖魔是什么锰瘸? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,791評(píng)論 1 295
  • 正文 為了忘掉前任刽严,我火速辦了婚禮,結(jié)果婚禮上避凝,老公的妹妹穿的比我還像新娘舞萄。我一直安慰自己,他們只是感情好管削,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,794評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布倒脓。 她就那樣靜靜地躺著,像睡著了一般含思。 火紅的嫁衣襯著肌膚如雪崎弃。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,631評(píng)論 1 305
  • 那天含潘,我揣著相機(jī)與錄音饲做,去河邊找鬼。 笑死遏弱,一個(gè)胖子當(dāng)著我的面吹牛盆均,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播漱逸,決...
    沈念sama閱讀 40,362評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼泪姨,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了饰抒?” 一聲冷哼從身側(cè)響起肮砾,我...
    開(kāi)封第一講書(shū)人閱讀 39,264評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎循集,沒(méi)想到半個(gè)月后唇敞,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,724評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡咒彤,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,900評(píng)論 3 336
  • 正文 我和宋清朗相戀三年疆柔,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片镶柱。...
    茶點(diǎn)故事閱讀 40,040評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡旷档,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出歇拆,到底是詐尸還是另有隱情鞋屈,我是刑警寧澤范咨,帶...
    沈念sama閱讀 35,742評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站厂庇,受9級(jí)特大地震影響渠啊,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜权旷,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,364評(píng)論 3 330
  • 文/蒙蒙 一替蛉、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧拄氯,春花似錦躲查、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,944評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至鄙麦,卻和暖如春典唇,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背黔衡。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,060評(píng)論 1 270
  • 我被黑心中介騙來(lái)泰國(guó)打工蚓聘, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留腌乡,地道東北人盟劫。 一個(gè)月前我還...
    沈念sama閱讀 48,247評(píng)論 3 371
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像与纽,于是被迫代替她去往敵國(guó)和親侣签。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,979評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容

  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫(kù)急迂、插件影所、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 12,105評(píng)論 4 62
  • 我再也見(jiàn)不到他了。內(nèi)心隱隱有火山要爆發(fā)僚碎。 就在五分鐘前猴娩,一個(gè)戴著口罩的男子找到我,見(jiàn)我獨(dú)自一人坐在操場(chǎng)上仰望星空勺阐,...
    凌天夜閱讀 309評(píng)論 0 2
  • 靜女其姝卷中,俟我于城隅。愛(ài)而不見(jiàn)渊抽,搔首踟躕蟆豫。靜女其孌,貽我彤管懒闷。彤管有煒十减,說(shuō)懌女美栈幸。自牧歸荑,洵美且異帮辟。匪女之為美速址,...
    濠濮之樂(lè)閱讀 393評(píng)論 0 4
  • 泉眼清凌凌的媚臉 澄澈,溫柔的可以撣去疼痛 我的眼珠有鼓出歲月的魚(yú)尾紋 與碧幽泉水的小白浪交融 一次次由驹,有人失足掉...
    938377db52e3閱讀 206評(píng)論 9 1