From 5376318f3d87fb2d2a9f15fbea64d94eb3ad96c1 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Mon, 12 Dec 2016 10:02:56 -0600 Subject: [PATCH] fix(img): move img requests out of web workers Due to iOS Cordova WKWebView limitations, ion-img is not able to use web workers for XMLHttpRequests. --- src/components/img/img-loader.ts | 205 +++++++++++++++++++--------- src/components/img/img.ts | 21 ++- src/components/img/test/img.spec.ts | 4 +- 3 files changed, 156 insertions(+), 74 deletions(-) diff --git a/src/components/img/img-loader.ts b/src/components/img/img-loader.ts index eb48a6c01c..e7d98a0efa 100644 --- a/src/components/img/img-loader.ts +++ b/src/components/img/img-loader.ts @@ -1,83 +1,166 @@ -import { Injectable } from '@angular/core'; -import { Config } from '../../config/config'; -import { removeArrayItem } from '../../util/util'; - - -@Injectable() export class ImgLoader { - private wkr: Worker; - private callbacks: Function[] = []; - private ids = 0; - private url: string; + private imgs: ImgData[] = []; - constructor(config: Config) { - this.url = config.get('imgWorkerUrl', IMG_WORKER_URL); - } + load(src: string, useCache: boolean, callback: ImgLoadCallback) { + // see if we already have image data for this src + let img = this.imgs.find(i => i.src === src); - load(src: string, cache: boolean, callback: Function) { - if (src) { - (callback).id = this.ids++; - this.callbacks.push(callback); - this.worker().postMessage(JSON.stringify({ - id: (callback).id, - src: src, - cache: cache - })); + if (img && img.datauri && useCache) { + // we found image data, and it's cool if we use the cache + // so let's respond with the cached data + callback(200, null, img.datauri); + return; } - } - cancelLoad(callback: Function) { - removeArrayItem(this.callbacks, callback); + // so no cached image data, so we'll + // need to do a new http request + + if (img && img.xhr && img.xhr.readyState !== 4) { + // looks like there's already an active http request going on + // for this same source, so let's just add another listener + img.xhr.addEventListener('load', (xhrEvent) => { + onXhrLoad(callback, xhrEvent, useCache, img, this.imgs); + }); + img.xhr.addEventListener('error', (xhrErrorEvent) => { + onXhrError(callback, img, xhrErrorEvent); + }); + return; + } + + if (!img) { + // no image data yet, so let's create it + img = { src: src }; + this.imgs.push(img); + } + + // ok, let's do a full request for the image + img.xhr = new XMLHttpRequest(); + img.xhr.open('GET', src, true); + img.xhr.responseType = 'arraybuffer'; + + // add the listeners if it loaded or errored + img.xhr.addEventListener('load', (xhrEvent) => { + onXhrLoad(callback, xhrEvent, useCache, img, this.imgs); + }); + img.xhr.addEventListener('error', (xhrErrorEvent) => { + onXhrError(callback, img, xhrErrorEvent); + }); + + // awesome, let's kick off the request + img.xhr.send(); } abort(src: string) { - if (src) { - this.worker().postMessage(JSON.stringify({ - src: src, - type: 'abort' - })); + const img = this.imgs.find(i => i.src === src); + if (img && img.xhr && img.xhr.readyState !== 4) { + // we found the image data and there's an active + // http request, so let's abort the request + img.xhr.abort(); + img.xhr = null; } } - private worker() { - if (!this.wkr) { - // create the worker - this.wkr = new Worker(this.url); +} - // create worker onmessage handler - this.wkr.onmessage = (ev: MessageEvent) => { - // we got something back from the web worker - // let's emit this out to everyone listening - const msg: ImgResponseMessage = JSON.parse(ev.data); - const callback = this.callbacks.find(cb => (cb).id === msg.id); - if (callback) { - callback(msg); - removeArrayItem(this.callbacks, callback); + +function onXhrLoad(callback: ImgLoadCallback, ev: any, useCache: boolean, img: ImgData, imgs: ImgData[]) { + if (!callback) { + return; + } + + // the http request has been loaded + // create a rsp object to send back to the main thread + const status: number = ev.target.status; + let datauri: string = null; + + if (status === 200) { + // success!! + // now let's convert the response arraybuffer data into a datauri + datauri = getDataUri(ev.target.getResponseHeader('Content-Type'), ev.target.response); + + if (useCache) { + // if the image was successfully downloaded + // and this image is allowed to be cached + // then let's add it to our image data for later use + img.datauri = datauri; + img.len = datauri.length; + + // let's loop through all our cached data and if we go + // over our limit then let's clean it out a bit + // oldest data should go first + var cacheSize = 0; + for (var i = imgs.length - 1; i >= 0; i--) { + cacheSize += imgs[i].len; + if (cacheSize > CACHE_LIMIT) { + console.debug(`img-loader, clear: ${imgs[i].src}, len: ${imgs[i].len}`); + imgs.splice(i, 1); } - }; - - // create worker onerror handler - this.wkr.onerror = (ev: ErrorEvent) => { - console.error(`ImgLoader, worker ${ev.type} ${ev.message ? ev.message : ''}`); - this.callbacks.length = 0; - this.wkr.terminate(); - this.wkr = null; - }; + } } - - // return that hard worker - return this.wkr; } + // fire the callback with what we've learned today + callback(status, null, datauri); } -const IMG_WORKER_URL = 'build/ion-img-worker.js'; -export interface ImgResponseMessage { - id: number; +function onXhrError(callback: ImgLoadCallback, imgData: ImgData, err: ErrorEvent) { + // darn, we got an error! + callback && callback(0, (err.message || ''), null); + imgData.xhr = null; +} + + +function getDataUri(contentType, arrayBuffer): string { + // take arraybuffer and content type and turn it into + // a datauri string that can be used by + const rtn: string[] = ['data:' + contentType + ';base64,']; + + const bytes = new Uint8Array(arrayBuffer); + const byteLength = bytes.byteLength; + const byteRemainder = byteLength % 3; + const mainLength = byteLength - byteRemainder; + let i, a, b, c, d, chunk; + + for (i = 0; i < mainLength; i = i + 3) { + chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]; + a = (chunk & 16515072) >> 18; + b = (chunk & 258048) >> 12; + c = (chunk & 4032) >> 6; + d = chunk & 63; + rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + ENCODINGS[d]); + } + + if (byteRemainder === 1) { + chunk = bytes[mainLength]; + a = (chunk & 252) >> 2; + b = (chunk & 3) << 4; + rtn.push(ENCODINGS[a] + ENCODINGS[b] + '=='); + + } else if (byteRemainder === 2) { + chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]; + a = (chunk & 64512) >> 10; + b = (chunk & 1008) >> 4; + c = (chunk & 15) << 2; + rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + '='); + } + + return rtn.join(''); +} + +// used by the setData function +const ENCODINGS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const CACHE_LIMIT = 1381855 * 20; + +export interface ImgData { src: string; - status?: number; - data?: string; - msg?: string; + datauri?: string; + len?: number; + xhr?: XMLHttpRequest; +} + +export type ImgLoadCallback = { + (status: number, msg: string, datauri: string): void; } diff --git a/src/components/img/img.ts b/src/components/img/img.ts index fa8bfa606e..5ec748568e 100644 --- a/src/components/img/img.ts +++ b/src/components/img/img.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestro import { Content } from '../content/content'; import { DomController } from '../../util/dom-controller'; -import { ImgLoader, ImgResponseMessage } from './img-loader'; +import { ImgLoader, ImgLoadCallback } from './img-loader'; import { isPresent, isTrueProperty } from '../../util/util'; import { Platform } from '../../platform/platform'; @@ -134,7 +134,7 @@ export class Img implements OnDestroy { /** @internal */ _cache: boolean = true; /** @internal */ - _cb: Function; + _cb: ImgLoadCallback; /** @internal */ _bounds: any; /** @internal */ @@ -235,9 +235,9 @@ export class Img implements OnDestroy { console.debug(`request ${this._src} ${Date.now()}`); this._requestingSrc = this._src; - // create a callback for when we get data back from the web worker - this._cb = (msg: ImgResponseMessage) => { - this._loadResponse(msg); + this._cb = (status, msg, datauri) => { + this._loadResponse(status, msg, datauri); + this._cb = null; }; // post the message to the web worker @@ -263,18 +263,18 @@ export class Img implements OnDestroy { } } - private _loadResponse(msg: ImgResponseMessage) { + private _loadResponse(status: number, msg: string, datauri: string) { this._requestingSrc = null; - if (msg.status === 200) { + if (status === 200) { // success :) - this._tmpDataUri = msg.data; + this._tmpDataUri = datauri; this.update(); } else { // error :( - if (msg.status) { - console.error(`img, status: ${msg.status} ${msg.msg}`); + if (status) { + console.error(`img, status: ${status} ${msg}`); } this._renderedSrc = this._tmpDataUri = null; this._dom.write(() => { @@ -413,7 +413,6 @@ export class Img implements OnDestroy { * @private */ ngOnDestroy() { - this._ldr.cancelLoad(this._cb); this._cb = null; this._content && this._content.removeImg(this); } diff --git a/src/components/img/test/img.spec.ts b/src/components/img/test/img.spec.ts index 54cee66279..2223fe6d82 100644 --- a/src/components/img/test/img.spec.ts +++ b/src/components/img/test/img.spec.ts @@ -2,7 +2,7 @@ import { ElementRef, Renderer } from '@angular/core'; import { Content } from '../../content/content'; import { Img } from '../img'; import { ImgLoader } from '../img-loader'; -import { mockConfig, mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers'; +import { mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers'; import { Platform } from '../../../platform/platform'; @@ -79,7 +79,7 @@ describe('Img', () => { beforeEach(() => { content = mockContent(); - ldr = new ImgLoader(mockConfig()); + ldr = new ImgLoader(); elementRef = mockElementRef(); renderer = mockRenderer(); platform = mockPlatform();