I am trying to achieve the following on iOS:
Always load local files to a UIWebView for static assets (.html, .js, etc.)
Allow an update protocol such that after some period of time we can return a different set of static assets for the same URLs
Download link for minimal example.
I have this working but it seems the NSURLCache is sometimes completely missed (reproducible) and the only reliable way to fix this has been to use nasty cachebusting tricks on the page being loaded. Another equally nasty hack is to destroy the UIWebView and create another.
As an example of this we have three versions of our webapp (red, blue and green for v1, v2 and v3 respectively):
Each screen is made up of a single HTML and JS file.
index.html
----------
<!DOCTYPE html>
<html>
<head>
</head>
<body style="background-color:#FF0000">
<h1 id="label" style="color:#FFFFFF"></h1>
<script src="app.js"></script>
</body>
</html>
app.js
------
var label = document.getElementById('label');
label.innerHTML = 'red';
Every 2 seconds the following happens:
We change what files the NSURLCache will return to make it return the different versions (note we implement this by overriding cachedResponseForRequest: rather than storeCachedResponse:forRequest:)
The UIWebView loads a dummy page "http://www.cacheddemo.co.uk/index.html"
The NSURLCache logic is implemented simply as a rotating NSMutableArray:
#implementation ExampleCache
- (id)init
{
self = [super initWithMemoryCapacity:8 * 1024 * 1024 diskCapacity:8 * 1024 * 1024 diskPath:#"webcache.db"];
if(self) {
[[NSNotificationCenter defaultCenter] addObserver:self selector:#selector(swapCache:) name:#"swapCache" object:nil];
self.cache = [#[#{#"index.html":#"index1.html", #"app.js":#"app1.js"},
#{#"index.html":#"index2.html", #"app.js":#"app2.js"},
#{#"index.html":#"index3.html", #"app.js":#"app3.js"}] mutableCopy];
}
return self;
}
- (void)swapCache:(NSNotification *)notification
{
[self.cache addObject:self.cache[0]];
[self.cache removeObjectAtIndex:0];
}
- (NSCachedURLResponse *)cachedResponseForRequest:(NSURLRequest *)request
{
NSString *file = [[[request URL] pathComponents] lastObject];
NSString *mimeType;
if([file hasSuffix:#".html"]) {
mimeType = #"text/html";
} else if([file hasSuffix:#".js"]) {
mimeType = #"application/javascript";
}
if(mimeType) {
NSString *cachedFile = self.cache[0][file];
NSUInteger indexOfDot = [cachedFile rangeOfString:#"."].location;
NSString *path = [[NSBundle mainBundle] pathForResource:[cachedFile substringToIndex:indexOfDot] ofType:[cachedFile substringFromIndex:indexOfDot + 1] inDirectory:#"www"];
NSData *data = [NSData dataWithContentsOfFile:path];
if(data.length) {
NSLog(#"Response returned for %#", file);
NSURLResponse *urlResponse = [[NSURLResponse alloc] initWithURL:[request URL] MIMEType:mimeType expectedContentLength:data.length textEncodingName:nil];
NSCachedURLResponse *response = [[NSCachedURLResponse alloc] initWithResponse:urlResponse data:data];
return response;
}
}
NSLog(#"No response for %# - %#", file, request);
return nil;
}
The view controller logic uses GCD to reload the UIWebView after the delay:
- (void)viewDidLoad
{
[super viewDidLoad];
self.webView = [[UIWebView alloc] initWithFrame:self.view.bounds];
[self.webView setAutoresizingMask:UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth];
[self.webView setDelegate:self];
[self.view addSubview:self.webView];
[self loadContent];
}
- (void)loadContent
{
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:#"%#?%d", #"http://www.cacheddemo.co.uk/index.html", arc4random()]]]];
// [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:#"http://www.cacheddemo.co.uk/index.html"]]];
double delayInSeconds = 2.0;
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayInSeconds * NSEC_PER_SEC));
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
[[NSNotificationCenter defaultCenter] postNotificationName:#"swapCache" object:nil];
[self loadContent];
});
}
The part I can not understand here is that adding the query string will make the page reloading work flawlessly (loads R - G - B - R - G - ...) - this is the uncommented line:
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:[NSString stringWithFormat:#"%#?%d", #"http://www.cacheddemo.co.uk/index.html", arc4random()]]]];
Once we get rid of the query string the NSURLCache stops being hit other than the first request so it just stays on the R page:
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:#"http://www.cacheddemo.co.uk/index.html"]]];
The fact that the query string causes the NSURLCache to act as it is supposed to indicates to me that the browser cache is interfering in some way. In my head I figure the caching levels work as so:
Check browser cache
If nothing returned so far - check NSURLCache
If nothing returned so far - check proxy server cache
Finally attempt to load the remote resource
How can we disable the browser cache entirely so we can completely control caching behaviour for UIWebView. Unfortunately I do not see an option to set the Cache-Control header in NSURLCache - I already tried returning a NSHTTPURLResponse with the headers set but this seems to be ignored.
I am not sure I understand correctly, but the cache-control has to be set on server side, with something like no-cache, expires and so on, and not on iOS side.
Second, by modifying the query string i.e. www.mysite.com/page?id=whatever..., iOS and any browser think the request is not the same, if you have opened the cache itself with some db editor, you should have seen one request, which is one database entry, for each changed query.
This trick of adding a random query string is quite useful for avoiding the browser to cache javascript file.
I hope I understand correctly your question.
The browser does appear to have its own "cache" above the networking layer's cache (i.e. NSURLCache).
Not sure if setting the cache control headers will solve it but if you want to try that you can do so in your code for cachedResponseForRequest. Where you create an NSURLResponse object you can use initWithURL:statusCode:HTTPVersion:headerFields: to set the header fields (including the cache control headers). In your cache control header you can try using both no-store and no-cache (e.g. http://blog.55minutes.com/2011/10/how-to-defeat-the-browser-back-button-cache/ ). If you try this please let us know if it does or does not work.
However, in practice I think the most pragmatic and maintainable solution will be to the use a number on the URL (i.e. cache busting). BTW instead of a random number you can use a simple increment that gets reset whenever the webview is allocated.
Related
I'm making a simple POST request with some body-params, constructed like so:
_webView = [[WKWebView alloc] initWithFrame:self.bounds configuration:[WKWebViewConfiguration new]];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:<some_URI>]];
[request setHTTPMethod:#"POST"];
NSString *paramsStr = #"someKey=someValue"
[request setHTTPBody:[paramsStr dataUsingEncoding:NSUTF8StringEncoding]];
[_webView loadRequest:request];
My server endpoint never receives any POST params (the http-body seems to be empty)
I've seen discussions about WKWebView not providing POST data in the navigation delegate, but the use-case for those questions has been to fetch form data off of the webView. Further, I read that that WKWebView bug has been fixed.
My use case is very simple, I just want to make a POST request from a webview, but it's still not working. I'm on iOS 14.4 FWIW. Any tips on what could be causing the POST data to not be available on the server?
Use JavaScript to solve the problem that WKWebView cannot send POST parameters
Before I start, let me talk about the implementation ideas, so that everyone can understand it. If something goes wrong, you can know the wrong place:
Put the HTML code of a POST request containing JavaScript in the project directory
Load the code of this POST request containing JavaScript to WKWebView
After loading, use Native to call JavaScript's POST method and pass in parameters to complete the request
HTML code to create a POST request containing JavaScript
Related code:
<html>
<head>
<script>
//调用格式: post('URL', {"key": "value"});
function post(path, params) {
var method = "post";
var form = document.createElement("form");
form.setAttribute("method", method);
form.setAttribute("action", path);
for(var key in params) {
if(params.hasOwnProperty(key)) {
var hiddenField = document.createElement("input");
hiddenField.setAttribute("type", "hidden");
hiddenField.setAttribute("name", key);
hiddenField.setAttribute("value", params[key]);
form.appendChild(hiddenField);
}
}
document.body.appendChild(form);
form.submit();
}
</script>
</head>
<body>
</body>
</html>
Copy this code and paste it into a text editor. You can choose any name, for example, save it as: JSPOST.html, and then copy it to the project directory. Remember to select the corresponding Target and check Copy items if needed (default It should be checked). At this time, you can use this JavaScript code to send a POST request with parameters.
Load the corresponding JavaScript code into WKWebView by loading a local web page
OC Code:
// JS sends the POST Flag, when it is true, it will call the JS POST method (only when the local JS is loaded for the first time)
self.needLoadJSPOST = YES;
// Create WKWebView
self.webView = [[WKWebView alloc] initWithFrame:[UIScreen mainScreen].bounds];
//Set up proxy
self.webView.navigationDelegate = self;
// Get the path where JS is located
NSString *path = [[NSBundle mainBundle] pathForResource:#"JSPOST" ofType:#"html"];
// Get html content
NSString *html = [[NSString alloc] initWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
// load js
[self.webView loadHTMLString:html baseURL:[[NSBundle mainBundle] bundleURL]];
// Add WKWebView to the current View
[self.view addSubview:self.webView];
This code is equivalent to loading the JavaScript scripts in the project into WKWebView, and we will see how to use it later. (Please change to your file name)
Native calls JavaScript scripts and passes in parameters to complete the POST request
Remember the section on the interaction between WKWebView and JavaScript? Now Native calls JavaScript. If you forget, please go ahead and review the story:-webView:didFinishNavigation: The proxy indicates that the page has been loaded. Let's do it here. The following code:
OC Code:
// Proxy method after loading
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
// Determine whether to load (only for the first time)
if (self.needLoadJSPOST) {
// Call the method of sending POST request using JS
[self postRequestWithJS];
// Set Flag to NO (you don’t need to load it later)
self.needLoadJSPOST = NO;
}
}
// Call JS to send POST request
- (void)postRequestWithJS {
// Send POST parameters
NSString *postData = #"\"username\":\"aaa\",\"password\":\"123\"";
// URL of the requested page
NSString *urlStr = #"http://www.postexample.com";
// Assembled into a string that calls JavaScript
NSString *jscript = [NSString stringWithFormat:#"post('%#', {%#});", urlStr, postData];
// NSLog(#"Javascript: %#", jscript);
// Call JS code
[self.webView evaluateJavaScript:jscript completionHandler:^(id object, NSError * _Nullable error) {
}];
}
you can see more here: http://www.qw021.com/article-22.html
I implemented a custom NSURLProtocol that allows me to use a static zipped version of a website as a target for a webView. It opens the zip on the go and load the required data.
But the issue is that NSURLProtocol seems not to behave properly with relative paths ? That is I have the following structure :
assets/css/main.css
assets/css/style.css
assets/images/sprite.png
index.html
And call sprite.png from the css using : background: url(../images/sprite.png) no-repeat;
but, the requestURL in my custom NSURLProtocol shows scheme://host/images/sprite.png, missing the assets part. It works fine if I switch the .. part for assets, but I would rather not have to do this.
I found the same issue here : Loading resources from relative paths through NSURLProtocol subclass but this got no answer.
I couldn't find any way to either fix this issue so that the request properly resolves the relative path, or fix the path myself afterwards (But i would need to know where the request originated from, and had no luck there either)
Any help appreciated, thanks in advance.
Side note :
Same problem using #import url("style.css"); in main.css
Edit :
I start by downloading the zip file from a remote server :
NSURL * fetchURL = [NSURL URLWithString:zipURLString];
[…]
NSString * filePath = [[self documentsDirectory] stringByAppendingPathComponent:fetchURL.path.lastPathComponent];
[zipData writeToFile:filePath atomically:YES];
So, from http://host/foo/archive.zip, i save it to documentsDirectory/archive.zip.
From there, I change the scheme and the url to point on the zip file :
NSString * str = [NSString stringWithFormat:#"myzip://%#", zipURL.path.lastPathComponent];
[_webView loadRequest:[NSURLRequest str]];
Which opens myzip://archive.zip, and if no such file was found in the zip file, I append /index.html to the current path.
Thus the following requests arrive in my NSURLProtocol subclass - (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id < NSURLProtocolClient >)client:
myzip://archive.zip (Changed to myzip://archive.zip/index.html)
myzip://archive.zip/assets/css/main.css
myzip://archive.zip/styles.css (Problem here)
Finally fixed it.
I had the following in my NSURLProtocol :
- (void)startLoading {
[self.client URLProtocol:self
didReceiveResponse:[[NSURLResponse alloc] init]
cacheStoragePolicy:NSURLCacheStorageNotAllowed];
//Some other stuff
}
and solved the issue with the following :
- (void)startLoading {
[self.client URLProtocol:self
didReceiveResponse:[[NSURLResponse alloc] initWithURL:_lastReqURL MIMEType:nil expectedContentLength:-1 textEncodingName:nil]
cacheStoragePolicy:NSURLCacheStorageNotAllowed];
//Some other stuff
}
Where _lastReqURL is _lastReqURL = request.URL;, from
- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id < NSURLProtocolClient >)client {
self = [super initWithRequest:request cachedResponse:cachedResponse client:client];
if (self) {
_lastReqURL = request.URL;
// Some stuff
}
}
I can only assume the URL part in NSURLResponse is critical when dealing with relative-paths (seems logical).
I think this might refer to the way you do load the Request or the HTML.
Could you paste the code for your request? I guess, you load the HTML locally, so don't forget to set the baseURL accordingly, else the relative pathes won't work anymore:
For example the following:
[self.webView loadHTMLString:html baseURL:[NSURL URLWithString:#"host"]];
I have a problem I have discovered in my app that has a UIWebView. iOS 7 caches a blank body 304 response, resulting in blank pages being shown when the user refreshes the UIWebView. This is not good user expierience and I'm trying to figure out how to solve this on the iOS side, as I do not have control over how Amazon S3 responds to headers (that's who I use for my resource hosting).
More details of this bug were found by these people: http://tech.vg.no/2013/10/02/ios7-bug-shows-white-page-when-getting-304-not-modified-from-server/
I'd appreciate any help offered to how I can solve this on the app side and not the server side.
Thank you.
Update: fixed this bug using the bounty's suggestion as a guideline:
#property (nonatomic, strong) NSString *lastURL;
- (void)webViewDidFinishLoad:(UIWebView *)webView
{
[UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
if ([self.webView stringByEvaluatingJavaScriptFromString:#"document.body.innerHTML"].length < 1)
{
NSLog(#"Reconstructing request...");
NSString *uniqueURL = [NSString stringWithFormat:#"%#?t=%#", self.lastURL, [[NSProcessInfo processInfo] globallyUniqueString]];
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:uniqueURL] cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData timeoutInterval:5.0]];
}
}
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
self.lastURL = [request.URL absoluteString];
return YES;
}
You can implement a NSURLProtocol, and then in +canonicalRequestForRequest: modify the request to override the cache policy.
This will work for all requests made, including for static resources in the web view which are not normally consulted with the public API delegate.
This is very powerful, and yet, rather easy to implement.
Here is more information:
http://nshipster.com/nsurlprotocol/
Reference:
https://developer.apple.com/library/ios/documentation/cocoa/reference/foundation/Classes/NSURLProtocol_Class/Reference/Reference.html
Here is an example:
#interface NoCacheProtocol : NSURLProtocol
#end
#implementation NoCacheProtocol
+ (void)load
{
[NSURLProtocol registerClass:[NoCacheProtocol class]];
}
+ (BOOL)canInitWithRequest:(NSURLRequest*)theRequest
{
if ([NSURLProtocol propertyForKey:#“ProtocolRequest” inRequest:theRequest] == nil) {
return YES;
}
return NO;
}
+ (NSURLRequest*)canonicalRequestForRequest:(NSURLRequest*)theRequest
{
NSMutableURLRequest* request = [theRequest mutableCopy];
[request setCachePolicy: NSURLRequestReloadIgnoringLocalCacheData];
//Prevent infinite recursion:
[NSURLProtocol setProperty:#YES forKey:#"ProtocolRequest" inRequest:request];
return request;
}
- (void)startLoading
{
//This is an example and very simple load..
[NSURLConnection sendAsynchronousRequest:self.request queue:[NSOperationQueue currentQueue] completionHandler:^ (NSURLResponse* response, NSData* data, NSError* error) {
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[[self client] URLProtocol:self didLoadData:data];
[[self client] URLProtocolDidFinishLoading:self];
}];
}
- (void)stopLoading
{
NSLog(#"something went wrong!");
}
#end
As the other questions are to use the NSURLConnection every time, which seems like a bit of an overhead:
Why don't you execute a small javascript after the page was loaded (complete or incomplete) that can tell you if the page is actually showing? Query for a tag that should be there (say your content div) and give a true/false back using the
[UIWebView stringByEvaluatingJavaScriptFromString:#"document.getElementById('adcHeader')!=null"]
And then, should that return false, you can reload the URL manually using the cache-breaker technique you described yourself:
NSString *uniqueURL = [NSString stringWithFormat:#"%#?t=%d", self.url, [[NSDate date] timeIntervalSince1970]];
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:uniqueURL] cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData timeoutInterval:5.0]];
[edit]
based on the discussion in the comments and some of the other answers, I think you might have the best solution manually changing the NSURLCache.
From what I gathered, you're mainly trying to solve a reload/reshow scenario. In that case, query the NSURLCache if a correct response is there, and if not delete the storedvalue before reloading the UIWebView.
[edit 2]
based on your new results, try to delete the NSURLCache when it is corrupted:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
NSCachedURLResponse *cachedResponse = [[NSURLCache sharedURLCache]cachedResponseForRequest:request];
if (cachedResponse != nil && [[cachedResponse data] length] > 0)
{
NSLog(#"%#",cachedResponse.response);
} else {
[[NSURLCache sharedURLCache] removeCachedResponseForRequest:request];
}
return YES;
}
We might have to refine the check if the cache is invalid again, but in theory this should do the trick!
NSURL *URL = [NSURL URLWithString:#"http://mywebsite.com"];
NSURLRequest *request = [NSURLRequest requestWithURL:URL
cachePolicy:NSURLRequestReloadIgnoringCacheData
timeoutInterval:30.0];
[myWebView loadRequest: request];
When creating NSURLRequest instance, you can set Cache Policy.
Hope it works!
I'm an iOS newb (.NET professional), so this may be a simple issue but I couldn't find anything through the SO search or Google (and maybe not looking for the right terms).
I'm writing an app that displays information from a DD-WRT router through it's web interface. I have no problem displaying the initial page and navigating through any of the other pages, but if I make any change on a form (and it redirects to apply.cgi or applyuser.cgi), the UIWebView is blank - it's supposed to display the same page, with the form submission changes. The site works fine in Mobile Safari, which I find intriguing, but I guess UIWebView isn't totally the same.
I think the iOS code is pretty standard for display a webpage, but I'll list it below. I can't give you access to my router because, well, that's not a good idea :) Hopefully someone with a DD-WRT router can help (or know what my issue is anyway).
- (void)viewDidLoad
{
[super viewDidLoad];
NSString *sURL = #"http://user:pass#XXX.XXX.X.X";
NSURL *url = [NSURL URLWithString:sURL];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[self.webView loadRequest:request];
self.webView.delegate = self ;
}
And I'm doing a few things with Javascript in the webViewDidFinishLoad method, but I know that's not the culprit because it still happens when I comment it out.
Well I figured out the problem on my own. I think part of it was putting the username & password in the URL (which was just a temporary measure) because I found that method provided the same results in mobile Safari and desktop Chrome.
So I added MKNetworkKit to my project that provided a simple way to add authentication to my request, and found I had to make a specific request to POST the data, then reloaded the page the to see the changes.
In the (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType method, I check if ([request.HTTPMethod isEqualToString:#"POST"]) and do this:
NSString *sPostData = [[NSString alloc] initWithData:request.HTTPBody encoding:NSUTF8StringEncoding];
NSArray *aPostData = [sPostData componentsSeparatedByString:#"&"];
NSMutableDictionary *dPostData = [[NSMutableDictionary alloc] init];
//i don't know if this is the best way to set a dictionary, but it works
for (id apd in aPostData)
{
NSString *key = [apd componentsSeparatedByString:#"="][0];
NSString *val = [apd componentsSeparatedByString:#"="][1];
[dPostData setValue:val forKey:key];
}
MKNetworkEngine *engine = [[MKNetworkEngine alloc] init];
MKNetworkOperation *op = [engine operationWithURLString:[request.URL description] params:dPostData httpMethod:#"POST"];
[op setUsername:#"myUserName" password:#"myPassword" basicAuth:YES];
self.postedRequest = TRUE; //a bool I set so, when it comes to webViewDidFinishLoad, I reload the current page
[op start]; //send POST operation
I'm serving up local images to my UIWebView via NSURLProtocol (which means the image is returned almost immediately), but I'm experiencing an issue where cached images (images being displayed again after their first load) take longer to load. Is there something in my NSURLProtocol causing this?
#implementation URLProtocol
+ (BOOL) canInitWithRequest:(NSURLRequest *)request {
return [request.URL.scheme isEqualToString:#"file"] ||
[request.URL.scheme isEqualToString:#"http"];
}
+ (NSURLRequest*) canonicalRequestForRequest:(NSURLRequest *)request {
return request;
}
- (void) startLoading {
id<NSURLProtocolClient> client = self.client;
NSURLRequest* request = self.request;
NSString *fileToLoad = request.URL.absoluteString;
NSURLResponse *response;
if([fileToLoad hasPrefix:#"http://app-fullpath/"]){
fileToLoad = [fileToLoad stringByReplacingOccurrencesOfString:#"http://app-fullpath/" withString:#""];
} else {
fileToLoad = [[NSURL URLWithString:fileToLoad] path];
}
NSData* data = [NSData dataWithContentsOfFile:fileToLoad];
response = [[NSHTTPURLResponse alloc] initWithURL:[request URL] statusCode:200 HTTPVersion:#"HTTP/1.1" headerFields:[NSDictionary dictionary]];
[client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[client URLProtocol:self didLoadData:data];
[client URLProtocolDidFinishLoading:self];
}
- (void) stopLoading { }
#end
Any speed suggestions, javascript/html or iOS?
My problem was that UIWebView gives text a much higher priority than images, so text is laid out first, then images are processed. In order to fix that I created a DOM representation of my HTML & Images, then I replaced all images with images loaded via javascript (new Image()) and they show instantly.