ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS WKWebView 파일 다운로드
    iOS 2024. 3. 15. 21:30

    웹뷰에서 파일을 다운로드하기위해서 다음과 같은 딜리게이트를 조합해서 사용.
     
    ▶︎ WKNavigationDelegate: 웹뷰의 내비게이션 요청을 추적하며, 내비게이션 변화에 대한 허용 또는 거절을 수행하는 기능 제공
    ▶︎ WKDownloadDelegate: 웹뷰 다운로드 및 진행 상황 추적 기능 제공
    ▶︎ UIDocumentInteractionControllerDelegate: 문서 미리보기, 공유 및 '파일'에 저장 기능 제공

     


    WKNavigationDelegate

    WKNavigationDelegate의 webView:decidePolicyForNavigationAction:preferences:decisionHandler: 메서드를 사용해서 웹뷰에서 내비게이션 시 낚아챌 수 있습니다. 아래와 같이 내비게이션을 종료하고 이미지, 엑셀, PDF 등의 파일을 다운로드할 수 있습니다.
     

    사용 예시

    WKNavigationDelegate를 사용하여 WKWebView를 조작하는 클래스.m

    // 이 글에서의 main context
    - (void)webView:(WKWebView *)webView 
    decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction 
    decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
        NSURL *url = [navigationAction.request URL];
        
        NSDictionary *result = [self.downloadUtil checkDownloadURL:url];
        
        if ([result[IS_DOWNLOAD_URL_KEY] boolValue]) {
            return decisionHandler([result[POLICY_KEY] intValue]);
        }
        
        //...
    }
    
    // 다운로드 시작 시 WKDownload의 delegate를 설정하는 딜리게이트 메서드
    - (void)webView:(WKWebView *)webView 
    navigationAction:(WKNavigationAction *)navigationAction 
    didBecomeDownload:(WKDownload *)download  API_AVAILABLE(ios(14.5)){
        download.delegate = self.downloadUtil;
    }

     

     

    DownloadUtil.h

    @import WebKit;
    #ifndef DownloadUtil_h
    #define DownloadUtil_h
    
    @interface DownloadUtil : NSObject <WKDownloadDelegate, UIDocumentInteractionControllerDelegate>
    // ...
    #endif

     

     

    DownloadUtil.m -  URL 기반

    - (NSDictionary *)checkDownloadURL:(NSURL *)url {
        NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
        NSMutableDictionary *result = [NSMutableDictionary dictionary];
        NSNumber *isDownloadURL = @NO;
        NSNumber *policy = nil;
        
        if ([self isContainFileDownloadSuffix:urlComponents.path]) { // Download file
            if (@available(iOS 14.5, *)) {
                isDownloadURL = @YES;
                policy = @(WKNavigationActionPolicyDownload);
            } else {
                isDownloadURL = @YES;
                policy = @(WKNavigationActionPolicyCancel);
                [self downloadFile:url];
            }
        }
        
        result[IS_DOWNLOAD_URL_KEY] = isDownloadURL;
        result[POLICY_KEY] = policy;
        return result;
    }
    
    // path에 파일 다운로드에 해당하는 식별자가 있을 때 YES 반환
    - (BOOL)isContainFileDownloadSuffix:(NSString *)path {
        NSArray<NSString *> *suffixes = [self getFileDownloadSuffixes];
        
        for (int i = 0; i < suffixes.count; i++) {
            if ([path containsString:suffixes[i]]) {
                return YES;
            }
        }
        
        return NO;
    }
    
    // MARK: - Suffix
    /// 파일 다운로드 URL 접미사(식별자) 배열 반환
    ///
    /// - returns: 접미사 배열
    - (NSArray<NSString *> *)getFileDownloadSuffixes {
        return @[
            // ...
        ];
    }

     

    위의 방법은 다운로드를 수행하는 URL인지 확인하는 접미사를 직접 지정해야 하기 때문에, 서버에 의존적이며 변경에 대응하기 어렵습니다. 따라서  webView:decidePolicyForNavigationResponse:decisionHandler: 메서드를 사용해서 Response의 MIME 타입을 추출해서 파일 다운로드를 수행할 수도 있습니다. 하지만 이 방법도 결국에는 서버에서 MIME 타입을 직접 지정해주는 것이기 때문에, 완벽한 방법은 아닙니다.

     

    DownloadUtil.m -  MIMEType 기반

    - (NSDictionary *)checkMimeType:(NSURLResponse *)response {
        NSMutableDictionary *result = [NSMutableDictionary dictionary];
        NSNumber *isDownloadURL = @NO;
        NSNumber *policy = nil;
        
        if ([self isContainFileDownloadMIMEType:response.MIMEType]) {
            if (@available(iOS 14.5, *)) {
                isDownloadURL = @YES;
                policy = @(WKNavigationResponsePolicyDownload);
            } else {
                isDownloadURL = @YES;
                policy = @(WKNavigationResponsePolicyCancel);
                [self downloadFile:response.URL];
            }
        }
        
        result[IS_DOWNLOAD_URL_KEY] = isDownloadURL;
        result[POLICY_KEY] = policy;
        return result;
    }
    
    - (BOOL)isContainFileDownloadMIMEType:(NSString *)MIMEType {
        NSArray<NSString *> *MIMETypes = [self getFileDownloadMIMETypes];
        
        for (int i = 0; i < MIMETypes.count; i++) {
            if ([MIMEType isEqualToString:MIMETypes[i]]) {
                return YES;
            }
        }
        
        return NO;
    }
    
    // MARK: - MIME TYPE
    - (NSArray<NSString *> *)getFileDownloadMIMETypes {
        return @[
            @"application/octet-stream",
            // 이미지
            @"image/gif",
            @"image/pjpeg",
            @"image/jpeg",
            @"image/png",
            @"image/x-png",
            // etc
            @"text/plain",
            @"application/zip"
        ];
    }

     

    여러가지 MIME 타입은 아래 MDN 링크에서 참고할 수 있습니다.

    (https://developer.mozilla.org/ko/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)


    WKDownloadDelegate

    (iOS 14.5 이상부터 가용)

    download:decideDestinationUsingResponse:suggestedFilename:completionHandler:

    Parameters

    ▶︎ download
    WKDownload 객체
     
    ▶︎ response
    일반적으로 HTTP 요청에 대한 서버의 응답 객체
     
    ▶︎  suggestedFilename
    일반적으로 응답 헤더에서 파일 이름을 가져옵니다. 이걸로 파일을 다운로드할 URL을 지정해줄 수 있습니다.
     
    ▶︎  completionHandler
    다운로드해서 저장할 URL을 넘겨줍니다. 다운로드를 취소하기 위해 nil을 넘겨줄 수도 있습니다. destination file URL은 아래 요구사항을 충족해야 합니다.

    • 파일이 이미 존재하면 안됩니다.
    • 디렉토리가 존재해야 합니다.
    • WebKit이 write할 수 있는 디렉토리여야 합니다.

     

    사용 예시

    // 1. 다운로드 시작
    - (void)download:(WKDownload *)download 
    decideDestinationUsingResponse:(NSURLResponse *)response
    suggestedFilename:(NSString *)suggestedFilename
    completionHandler:(void (^)(NSURL * _Nullable))completionHandler  API_AVAILABLE(ios(14.5)){
        
        NSURL *filePath = [[[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory
                                                                   inDomains:NSUserDomainMask] firstObject] URLByAppendingPathComponent:suggestedFilename];
        NSFileManager *fileManager = [NSFileManager defaultManager];
        
        if ([fileManager fileExistsAtPath:filePath.path]) {
            if ([self removeFile:fileManager url:filePath error:nil]) {
                self.downloadFilePath = filePath;
                completionHandler(filePath);
            } else {
                completionHandler(nil); // 파일 삭제를 실패했다면, 다운로드 취소
            }
        } else {
            self.downloadFilePath = filePath; // 메모리에 destination URL 저장
            completionHandler(filePath);
        }
    }
    
    // 2. 다운로드 정상적으로 종료 시 호출
    - (void)downloadDidFinish:(WKDownload *)download  API_AVAILABLE(ios(14.5)){
        if (self.downloadFilePath != nil) {
            [self documentInteractionControllerWillAppear:self.downloadFilePath];
            self.downloadFilePath = nil;
        } else {
            [self alertWillAppear:UNDEFINED_ERROR_MESSAGE];
        }
    }
    
    // 3. 미리보기 뷰 띄우기
    - (void)documentInteractionControllerWillAppear:(NSURL *) url {
        UIDocumentInteractionController *documentController = [UIDocumentInteractionController interactionControllerWithURL:url];
        documentController.delegate = self;
        
        dispatch_async(dispatch_get_main_queue(), ^{
            [documentController presentPreviewAnimated:YES];
        });
    }

     

     

    WKDownloadDelegate가 iOS 14.5부터 가용하기 때문에, 이전 버전에서는 직접 다운로드 처리를 해줌.

    1. NSURLSessionDownloadTask로 파일 다운로드
    2. NSFileManager를 사용해서 앱 내부 파일 시스템에 접근하여 파일 조작
    3. UIDocumentInteractionController를 사용해서 미리보기 뷰 띄우기
    - (void)downloadFile:(NSURL *)url {
        NSURLSessionDownloadTask *downloadTask = [[NSURLSession sharedSession] downloadTaskWithURL:url
                                                                               completionHandler:^(NSURL *location,
                                                                                                   NSURLResponse *response,
                                                                                                   NSError *error) {
            if (error == nil) {
                NSFileManager *fileManager = [NSFileManager defaultManager];
                NSError *fileError;
                
                NSString *suggestedFilename = response.suggestedFilename;
                
                if (suggestedFilename != nil) {
                    NSURL *documentDirectory = [[fileManager URLsForDirectory:NSDocumentDirectory 
                                                                    inDomains:NSUserDomainMask] firstObject];
                    NSURL *newFileURL = [documentDirectory URLByAppendingPathComponent:suggestedFilename];
                    
                    if ([self renameFile:fileManager from:location to:newFileURL error:fileError]) {
                        [self documentInteractionControllerWillAppear:newFileURL];
                    }
                } else {
                    [self alertWillAppear:UNDEFINED_ERROR_MESSAGE];
                }
            } else {
                [self alertWillAppear:DOWNLOAD_ERROR_MESSAGE];
            }
        }];
        
        [downloadTask resume];
    }
    
    - (BOOL)renameFile:(NSFileManager *)fileManager
                  from:(NSURL *)fromURL
                    to:(NSURL *)toURL
                 error:(NSError *)error {
        BOOL success = NO;
        
        if ([fileManager fileExistsAtPath:toURL.path]) {
            if ([self removeFile:fileManager url:toURL error:error]) {
                success = [self moveFile:fileManager from:fromURL to:toURL error:error];
            }
        } else {
            success = [self moveFile:fileManager from:fromURL to:toURL error:error];
        }
        
        return success;
    }
    
    - (BOOL)moveFile:(NSFileManager *)fileManager
                from:(NSURL *)fromURL
                  to:(NSURL *)toURL
               error:(NSError *)error {
        if ([fileManager moveItemAtURL:fromURL toURL:toURL error:&error]) {
            return YES;
        } else {
            [self alertWillAppear:UNDEFINED_ERROR_MESSAGE];
            return NO;
        }
    }
    
    - (BOOL)removeFile:(NSFileManager *)fileManager
                   url:(NSURL *)url
                 error:(NSError *)error {
        if ([fileManager removeItemAtURL:url error:&error]) {
            #if DEBUG
            NSLog(@"파일 삭제 성공: %@", url);
            #endif
            return YES;
        } else {
            [self alertWillAppear:UNDEFINED_ERROR_MESSAGE];
            return NO;
        }
    }

    UIDocumentInteractionControllerDelegate

    UIDocumentInteractionController와 딜리게이트를 사용해서, 사용자가 다운로드한 임시 파일을 공유하거나 '파일' 앱에 다운로드할 수 있도록 해주는 미리보기 뷰를 띄울 수 있습니다.

     

    사용 예시

    -(UIViewController*) documentInteractionControllerViewControllerForPreview:(UIDocumentInteractionController *)controller {
        return self.viewController; // parent for the document preview
    }
    
    - (void)documentInteractionControllerDidEndPreview:(UIDocumentInteractionController *)controller {
        NSFileManager* fileManager = [NSFileManager defaultManager];
        NSError *error;
        [self removeFile:fileManager url: controller.URL error:error]; // 임시 파일 삭제
    }

     


    + WKUIDelegate

    자바스크립트 단에서 Window 객체의 open()과 같은 메서드로 이동 또는 파일 다운로드를 수행한다면, 위의 구성만으로는 동작하지 않을 수도 있습니다. 기본적으로 웹뷰 하나당 하나의 window를 가정하기 때문에 내비게이션을 취소해 버리기 때문입니다. 따라서 WKNavigationDelegate의  webView:decidePolicyForNavigationAction:preferences:decisionHandler: 메서드도 호출하지 않습니다. 이때, WKUIDelegate를 구현하여 내비게이션을 수행할 수 있습니다.

    // WKUIDelegate 구현 클래스 및 set 코드 생략
    
    //  If you do not implement this method, the web view will cancel the navigation.
    -(WKWebView*) webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
        [webView loadRequest:navigationAction.request];
        return  nil;
    }

     

    댓글

Designed by Tistory.