47

Similar questions have been asked before, but I could never find a solution.

Here is my situation - my UIWebView loads a remote html page. The images used in the web pages are known at build time. In order to make the page load faster, I want to package the image files in the iOS application and substitue them at runtime.

[Please note that the html is remote. I always get answers for loading both html and image files from local - I have done that already]

The closest recommendation I got was to use a custom url scheme such as myapp://images/img.png in the html page and in the iOS application, intercept the myapp:// URL with NSURLProtocol subclass and replace the image with a local image. Sounded good in theory, but I haven't come across a complete code example demonstrating this.

I have Java background. I could do this easily for Android using a Custom Content Provider. I am sure a similar solution must exist for iOS/Objective-C. I don't have enough experience in Objective-C to solve it myself in the short timeframe I have.

Any help will be appreciated.

riven
  • 1,476
  • 2
  • 12
  • 15
CM Subram
  • 473
  • 1
  • 5
  • 4

5 Answers5

86

Ok here is an example how to subclass NSURLProtocol and deliver an image (image1.png) which is already in the bundle. Below is the subclasses' header, the implementation as well as an example how to use it in a viewController(incomplete code) and a local html file(which can be easily exchanged with a remote one). I've called the custom protocol: myapp:// as you can see in the html file at the bottom.

And thanks for the question! I was asking this myself for quite a long time, the time it took to figure this out was worth every second.

EDIT: If someone has difficulties making my code run under the current iOS version, please have a look at the answer from sjs. When I answered the question it was working though. He's pointing out some helpful additions and corrected some issues, so give props to him as well.

This is how it looks in my simulator:

enter image description here

MyCustomURLProtocol.h

@interface MyCustomURLProtocol : NSURLProtocol
{
    NSURLRequest *request;
}

@property (nonatomic, retain) NSURLRequest *request;

@end

MyCustomURLProtocol.m

#import "MyCustomURLProtocol.h"

@implementation MyCustomURLProtocol

@synthesize request;

+ (BOOL)canInitWithRequest:(NSURLRequest*)theRequest
{
    if ([theRequest.URL.scheme caseInsensitiveCompare:@"myapp"] == NSOrderedSame) {
        return YES;
    }
    return NO;
}

+ (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest*)theRequest
{
    return theRequest;
}

- (void)startLoading
{
    NSLog(@"%@", request.URL);
    NSURLResponse *response = [[NSURLResponse alloc] initWithURL:[request URL] 
                                                        MIMEType:@"image/png" 
                                           expectedContentLength:-1 
                                                textEncodingName:nil];

    NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"image1" ofType:@"png"];  
    NSData *data = [NSData dataWithContentsOfFile:imagePath];

    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [[self client] URLProtocol:self didLoadData:data];
    [[self client] URLProtocolDidFinishLoading:self];
    [response release];
}

- (void)stopLoading
{
    NSLog(@"something went wrong!");
}

@end

MyCustomProtocolViewController.h

@interface MyCustomProtocolViewController : UIViewController {
    UIWebView *webView;
}

@property (nonatomic, retain) UIWebView *webView;

@end

MyCustomProtocolViewController.m

...

@implementation MyCustomProtocolViewController

@synthesize webView;

- (void)awakeFromNib
{
    self.webView = [[[UIWebView alloc] initWithFrame:CGRectMake(20, 20, 280, 420)] autorelease];
    [self.view addSubview:webView];
}

- (void)viewDidLoad
{   
    // ----> IMPORTANT!!! :) <----
    [NSURLProtocol registerClass:[MyCustomURLProtocol class]];

    NSString * localHtmlFilePath = [[NSBundle mainBundle] pathForResource:@"file" ofType:@"html"];

    NSString * localHtmlFileURL = [NSString stringWithFormat:@"file://%@", localHtmlFilePath];

    [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:localHtmlFileURL]]];

    NSString *html = [NSString stringWithContentsOfFile:localHtmlFilePath encoding:NSUTF8StringEncoding error:nil]; 

    [webView loadHTMLString:html baseURL:nil];
}

file.html

<html>
<body>
    <h1>we are loading a custom protocol</h1>
    <b>image?</b><br/>
    <img src="myapp://image1.png" />
<body>
</html>
Nick Weaver
  • 47,228
  • 12
  • 98
  • 108
  • Fantastic! Looks like exactly what I was looking for. I will try this out and let you know. – CM Subram Apr 06 '11 at 21:37
  • Nick, I tried your solution and this works exactly as I wanted and as you had described. I am yet to try it out for remote html, but I believe it should work. If you are a freelancer and like to help out I would certainly like to talk to you for some small/micro scale paid services :-) You can reach me at cmsubram at gmail. Thanks, CM – CM Subram Apr 07 '11 at 00:44
  • 1
    Why is request declared as a property of the subclass? NSURLProtocol already has a `request` property so you should just be using `self.request`. In the code above `request` is always `nil`. – Sami Samhuri Nov 26 '11 at 00:38
  • 1
    @sjs Good point, I can't tell you what I've thought when I introduced that property, however there was no harm done, as the example worked fine back then. And no the request is not nil, this is a registered subclass of NSURLProtocol. Look at the static registerClass method's documentation. – Nick Weaver Nov 26 '11 at 16:07
  • 1
    @NickWeaver I have read the documentation. `request` is never assigned and is `nil`. I'm using your code and in order to make it work I had to use the property `self.request`. Try this code today, it does not work. Even if it did work an unused ivar is cruft that should be removed. If you read the documentation you'll also see that -[NSURLProtocol stopLoading] is not an error condition. You shouldn't be logging "Something went wrong!" when that is a regular part of a successful request cycle. – Sami Samhuri Nov 27 '11 at 18:43
  • @NickWeaver can i add action in button inside webview? – Krutarth Patel Jan 09 '17 at 05:14
  • 1
    @Krutarth Patel Not directly. You will have to add a button/link in your html which triggers loading a certain URL. Then you will have to intercept this in startLoading or in one of the UIWebViewDelegate method such as - webViewDidStartLoad:. – Nick Weaver Jan 09 '17 at 08:31
39

Nick Weaver has the right idea but the code in his answer does not work. It breaks some naming conventions as well, never name your own classes with the NS prefix, and follow the convention of capitalizing acronyms such as URL in identifier names. I'll stick w/ his naming in the interest of making this easy to follow.

The changes are subtle but important: lose the unassigned request ivar and instead refer to the the actual request provided by NSURLProtocol and it works fine.

NSURLProtocolCustom.h

@interface NSURLProtocolCustom : NSURLProtocol
@end

NSURLProtocolCustom.m

#import "NSURLProtocolCustom.h"

@implementation NSURLProtocolCustom

+ (BOOL)canInitWithRequest:(NSURLRequest*)theRequest
{
    if ([theRequest.URL.scheme caseInsensitiveCompare:@"myapp"] == NSOrderedSame) {
        return YES;
    }
    return NO;
}

+ (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest*)theRequest
{
    return theRequest;
}

- (void)startLoading
{
    NSLog(@"%@", self.request.URL);
    NSURLResponse *response = [[NSURLResponse alloc] initWithURL:self.request.URL 
                                                        MIMEType:@"image/png" 
                                           expectedContentLength:-1 
                                                textEncodingName:nil];

    NSString *imagePath = [[NSBundle mainBundle] pathForResource:@"image1" ofType:@"png"];  
    NSData *data = [NSData dataWithContentsOfFile:imagePath];

    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [[self client] URLProtocol:self didLoadData:data];
    [[self client] URLProtocolDidFinishLoading:self];
    [response release];
}

- (void)stopLoading
{
    NSLog(@"request cancelled. stop loading the response, if possible");
}

@end

The problem with Nick's code is that subclasses of NSURLProtocol do not need to store the request. NSURLProtocol already has the request and you can access with the method -[NSURLProtocol request] or the property of the same name. Since the request ivar in his original code is never assigned it is always nil (and if it was assigned it should have been released somewhere). That code cannot and does not work.

Second, I recommend reading the file data before creating the response and passing [data length] as the expected content length instead of -1.

And finally, -[NSURLProtocol stopLoading] is not necessarily an error, it just means you should stop work on a response, if possible. The user may have cancelled it.

Sami Samhuri
  • 1,540
  • 17
  • 21
2

I hope I am understanding your problem correctly:

1) load a remote webpage ... and

2) substitute certain remote assets with files within the app/build

Right?


Well, what I am doing is as follows (I use it for videos due to the caching limit of 5MB on Mobile Safari, but I think any other DOM content should work equally):


• create a local (to be compiled with Xcode) HTML page with style tags, for the in-app/build content to be substituted, set to hidden, e.g.:

<div style="display: none;">
<div id="video">
    <video width="614" controls webkit-playsinline>
            <source src="myvideo.mp4">
    </video>
</div>
</div> 


• in the same file supply a content div, e.g.

<div id="content"></div>


• (using jQuery here) load the actual content from the remote server and append your local (Xcode imported asset) to your target div, e.g.

<script src="jquery.js"></script>
<script>
    $(document).ready(function(){
        $("#content").load("http://www.yourserver.com/index-test.html", function(){
               $("#video").appendTo($(this).find("#destination"));           
        });

    });
</script>


• drop the www files (index.html / jquery.js / etc ... use root levels for testing) into the project and connect to target


• the remote HTML file (here located at yourserver.com/index-test.html) having a

<base href="http://www.yourserver.com/">


• as well as a destination div, e.g.

<div id="destination"></div>


• and finally in your Xcode project, load the local HTML into the web view

self.myWebView = [[UIWebView alloc]init];

NSURL *baseURL = [NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]];
NSString *path = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
[self.myWebView loadHTMLString:content baseURL:baseURL];

Works a treat for me, best in conjunction with https://github.com/rnapier/RNCachingURLProtocol, for offline caching. Hope this helps. F

1

The trick is to provide the explicit base URL to an existing HTML.

Load the HTML into a NSString, use UIWebView's loadHTMLString: baseURL: with the URL into your bundle as the base. For loading HTML into a string, you can use [NSString stringWithContentsOfURL], but that's a synchronous method, and on slow connection it will freeze the device. Using an async request to load the HTML is also possible, but more involved. Read up on NSURLConnection.

Seva Alekseyev
  • 59,826
  • 25
  • 160
  • 281
0

NSURLProtocol is a good choice for UIWebView, but until now the WKWebView still not support it. For WKWebView we can build a local HTTP server to handle the local file request, the GCDWebServer is good for this:

self.webServer = [[GCDWebServer alloc] init];

[self.webServer addDefaultHandlerForMethod:@"GET"
                              requestClass:[GCDWebServerRequest class]
                              processBlock:
 ^GCDWebServerResponse *(GCDWebServerRequest *request)
{
    NSString *fp = request.URL.path;

    if([[NSFileManager defaultManager] fileExistsAtPath:fp]){
        NSData *dt = [NSData dataWithContentsOfFile:fp];

        NSString *ct = nil;
        NSString *ext = request.URL.pathExtension;

        BOOL (^IsExtInSide)(NSArray<NSString *> *) = ^(NSArray<NSString *> *pool){
            NSUInteger index = [pool indexOfObjectWithOptions:NSEnumerationConcurrent
                                                  passingTest:^BOOL(NSString *obj, NSUInteger idx, BOOL *stop) {
                                                      return [ext caseInsensitiveCompare:obj] == NSOrderedSame;
                                                  }];
            BOOL b = (index != NSNotFound);
            return b;
        };

        if(IsExtInSide(@[@"jpg", @"jpeg"])){
            ct = @"image/jpeg";
        }else if(IsExtInSide(@[@"png"])){
            ct = @"image/png";
        }
        //else if(...) // other exts

        return [GCDWebServerDataResponse responseWithData:dt contentType:ct];

    }else{
        return [GCDWebServerResponse responseWithStatusCode:404];
    }

}];

[self.webServer startWithPort:LocalFileServerPort bonjourName:nil];

When specify the file path of the local file, add the local server prefix:

NSString *fp = [[NSBundle mainBundle] pathForResource:@"picture" ofType:@"jpg" inDirectory:@"www"];
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:%d%@", LocalFileServerPort, fp]];
NSString *str = url.absoluteString;
[self.webViewController executeJavascript:[NSString stringWithFormat:@"updateLocalImage('%@')", str]];
Albert Zhang
  • 757
  • 1
  • 8
  • 19