iOS使用UITableView實現的富文字編輯器

aron1992發表於2019-04-04

前言

公司最近做一個專案,其中有一個模組是富文字編輯模組,之前沒做個類似的功能模組,本來以為這個功能很常見應該會有已經造好的輪子,或許我只要找到輪子,研究下輪子,然後修改打磨輪子,這件事就八九不離十了。不過,還是 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完成上傳檔案

相關文章