PouchDB + SQLite para Local Storage no IONIC 4
Tenho focado meus esforços no back em ambiente AWS, por isso textos sobre Angular e IONIC tem sido cada vez menos frequentes por aqui. Vamos então a uma atualização sobre oque está rolando com o IONIC 4.x, e como ferramenta de estudo vamos criar uma aplicação que irá utilizar o PouchDB.
Vamos falar sobre, como utilizar o PouchDB com SQLite no IONIC, não iremos tratar da sincronização de dados entre PouchDB e CouchDB, vamos focar no armazenamento local.
Oque é o PouchDB?
PouchDB é uma biblioteca JavaScript e open-source que utiliza o IndexDB ou WebSQL para armazenar os dados em seu navegador, isso mesmo, em seu navegado. Isto significa que você não precisa executar um servidor de banco de dados em um dispositivo móvel (smartphones), como estamos falando de IONIC, e ele vai rodar em um navegador (browser), está fácil. PouchDB foi inspirado pelo projeto Apache CouchDB e possuí uma integração, ou meio de sincronização de seus dados entre eles, por isto é comum vermos aplicações utilizando ambos.
PouchDB utiliza a abordagem NoSQL de armazenamento de dados, que costuma simplificar o código da aplicação no aspecto de que não precisamos mapear o mundo relacional para o mundo dos objetos (ORM), outros aspectos como escrever consultar, trocamos um problema por outro, na minha percepção, ai tenho de decidir qual quero enfrentar.
Existem limites de armazenamento no IndexDB e WebSQL, se você está procurando armazenamento estes limites e confiável para dispositivo móvel, é melhor partir para o SQLite ou outra ferramenta.
Antes da versão 6.0.0 do PouchDB o uso do SQLite era quase automático, então, fique atento na versão que você está utilizando, pois a partir desta versão mudou.
O SQLite é mais lento que o IndexDB/WebSQL como mencionado neste texto aqui.
Criando o projeto
$ ionic start birthday blank
$ cd birthday
$ ionic serve
HomePage — Estático — Alterando o atual
Vamos alterar o home.page.html atual para o conteúdo estático abaixo, assim, teremos um protótipo para depois implementarmos o comportamento.
<ion-header>
<ion-toolbar>
<ion-title> 🎂 Birthdays 🎉</ion-title>
<ion-buttons slot="end">
<ion-button>
<ion-icon name="add"></ion-icon>
</ion-button>
</ion-buttons></ion-toolbar>
</ion-header>
<ion-content>
<ion-list inset="true">
<ion-item>
<div slot="start">Clayton Passos</div>
<div slot="end"> 29/06/1980</div>
</ion-item>
<ion-item>
<div slot="start">Daniela</div>
<div slot="end"> 02/07/1982</div>
</ion-item>
<ion-item>
<div slot="start">Catherine</div>
<div slot="end"> 01/01/2008</div>
</ion-item>
<ion-item>
<div slot="start">Sophia</div>
<div slot="end"> 10/05/2014</div>
</ion-item>
</ion-list>
</ion-content>
Este HTML, vai nos entregar algo assim:
Algumas versões do Angular (começando na 6) é utilizado a API de internacionalização para formatar datas (date), é bom, mas não funciona no Safari 😠 😭. Você tem duas opções, pode usar um polyfill ou escrever seu próprio pipe de formatação de datas. Neste nosso exemplo, vamos utilizar o polyfill, para isto adicione apenas a linha abaixo no app.html
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Intl.~locale.en"></script>
Veja o arquivo polyfills.ts no seu projeto, tem alguns comentários sobre outras coisas que não funcionam no Safari, Edge …
DetailsPage — Estático
OK, agora vamos criar a página details.page.html dentro do diretório page.
$ ionic generate page page/details
Neste momento, em que vou analisar o código existente (home, app) e o gerado, percebo que temos uma estrutura ainda mais parecida com o Angular, vejo o mesmo modelo de controle de rotas, gostei ;). Veja o app-routing.module.ts que foi alterado e foi incluído nele a rota para abrirmos esta página.
const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', loadChildren: './home/home.module#HomePageModule' },
{ path: 'details', loadChildren: './page/details/details.module#DetailsPageModule' },
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }
Vamos alterar o conteúdo de details.page.html gerado para:
<ion-header>
<ion-toolbar>
<ion-title>{{ action }} Birthday</ion-title>
<ion-buttons slot="end">
<ion-button>
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-list>
<ion-item>
<ion-label>Name</ion-label>
<ion-input class="ion-text-end" type="text" value="Clayton Passos"></ion-input>
</ion-item>
<ion-item>
<ion-label>Birthday</ion-label>
<ion-datetime displayFormat="MMMM D, YYYY" pickerFormat="MMMM D YYYY" [value]="'2012-12-19T06:01:17.171Z'"></ion-datetime>
</ion-item>
</ion-list>
<ion-button color="primary" expand="block">Save</ion-button>
</ion-content>
Provavelmente você irá acessar esta página por este endereço: http://localhost:8101/details
Com isto, conseguimos ver as duas páginas que iremos trabalhar nesta aplicação de forma estática. Vamos começar a incluir o comportamento.
Abrindo details.page.html como um modal, não através da rota
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild([
{
path: '',
component: HomePage
}
])
],
declarations: [HomePage, DetailsPage],
entryComponents: [DetailsPage]
})
export class HomePageModule {
}
Adicione ao home.page.ts o método showDatail de maneira que utilize a classe ModalCotroller para abrir a classe DetailsPage, assim:
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
public birthdays = [];
constructor(private modalCtrl: ModalController) {
}
async showDetail(birthday) {
// mudou https://ionicframework.com/docs/api/modal
// parâmetro de entrada pra criar o modal mudou
// o retorno agora é um promisse
const modal = await this.modalCtrl.create({
component: DetailsPage,
componentProps: {
birthday // é um atalho para birthday: birthday
}
});
return await modal.present();
}
}
Agora pode alterar o <io-header> do home.page.html para fazer com que o clique no botão execute o metodo showDetail, assim:
<ion-header>
<ion-toolbar> <!-- Antes utilizavamos <ion-navbar> -->
<ion-title> 🎂 Birthdays 🎉 </ion-title>
<ion-buttons slot="end">
<ion-button (click)="showDetail()">
<ion-icon name="add" ></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
Cadastro de aniversariantes
Vamos dar uma olhada na ajuda do scaffolding do ionic e verificar como podemos utiliza-lo para gerar um Service.
$ ionic generate service --help
Então, executaremos o comando a baixo para gerar a classe dentro da pasta service:
$ ionic generate service service/Birthday
Observei duas mudanças em relação a versões anteriores, primeiro que ele criou a nossa classe já injetando-o no root.
@Injectable({
providedIn: 'root'
})
export class BirthdayService {
constructor() { }
}
Segunda observação, foi gerado uma classe de testes birthday.service.spec.ts , como é feito no Angular, isto é muito bom, pois anteriormente não tínhamos suporte oficial a testes automatizados, agora temos :D Gostei!
Adicione os métodos, com uma implementação vazia, como abaixo:
add(birthday) {
console.log('add ' + birthday);
}
update(birthday) {
console.log('update ' + birthday);
}
delete(birthday) {
console.log('delete ' + birthday);
}
getAll() {
console.log('getAll ');
}
Queremos que o getAll seja seja executado logo que home.page.ts for inicializado. Para isto, precisamos entender ciclo de vida de um componente Angular, e do componente IONIC.
Por enquanto vamos utilizar o ngOnInit do Angular, caso queira mais detalhes consulte a documentação aqui.
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {
public birthdays = [];
constructor(private birthdayService: BirthdayService,
private modalCtrl: ModalController) {
}
ngOnInit() {
this.birthdayService.getAll();
}
async showDetail(birthday) {
...
}
}
Para verificar se funcionou, veja no console do seu navegador, deve haver o log ao abrir a aplicação, talvez você tenha de parar a execução atual e executar novamente.
Altere o details.page.ts para que seja injetado o BirthdayService e adicione o método save e delete, assim:
@Component({
selector: 'app-details',
templateUrl: './details.page.html',
styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
public birthday: any = {};
constructor(private birthdayService: BirthdayService) {
}
ngOnInit() {
}
save() {
this.birthdayService.add(this.birthday);
}
delete() {
this.birthdayService.delete(this.birthday);
}
}
Altere o details.page.html para que execute o save e o delete nos botões corretos, assim:
<ion-header>
<ion-toolbar>
<ion-title>{{ action }} Birthday</ion-title>
<ion-buttons slot="end">
<ion-button (click)="delete()">
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-list>
<ion-item>
<ion-label>Name</ion-label>
<ion-input class="ion-text-end" type="text" value="Clayton Passos"></ion-input>
</ion-item>
<ion-item>
<ion-label>Birthday</ion-label>
<ion-datetime displayFormat="MMMM D, YYYY" pickerFormat="MMMM D YYYY" [value]="'2012-12-19T06:01:17.171Z'">
</ion-datetime>
</ion-item>
</ion-list>
<ion-button color="primary" expand="block" (click)="save()">Save</ion-button>
</ion-content>
OK, agora só falta implementar o birthday.sevice.ts dando a ele o comportamento que queremos, e a lógica necessária nos Controllers (Page) para acessa-la.
PouchDB + SQLite E o adapter
Agora vamos instalar algumas algumas coisas na aplicação para poder utilizar o PouchDB com o SQLite
Primeiro, tenha certeza de que você tem instalado o cordova, não apenas o IONIC, caso precise instale executando isto na linha de comado:
$ npm i -g cordova
Agora, vamos instalar o SQLite Plugin 2
$ ionic cordova plugin add cordova-plugin-sqlite-2 --save
ou
$ cordova plugin add cordova-plugin-sqlite-2
Então, vamos instalar o PouchDB e o PouchDB SQLite Adapter
$ npm install pouchdb pouchdb-adapter-cordova-sqlite --save
Abra o arquivo polyfills.ts e adicione no final ):
(window as any).global = window;
Sem isto, não conseguiremos utilizar o PouchDB, teremos um erro como este (veja mais):
Vamos alterar este código do birthday.service.ts para fazer algumas coisas:
- Definir ao PouchDB que utilize o cordova sqlite plugin, para que utilizemos o SQLite como banco de dados se disponível, se não será utilizado o WebSQL automáticamente. (isto quer dizer que no seu dispositivo móvel será utilizado o SQLite, e no navegador do seu computador, enquanto você desenvolve será utilizado o WebSQL.
- Iniciar o banco de dados;
- Se não existir o banco de dados um novo seja criado.
import {Injectable} from '@angular/core';
import * as PouchDB from 'pouchdb';
import cordovaSqlitePlugin from 'pouchdb-adapter-cordova-sqlite';
@Injectable({
providedIn: 'root'
})
export class BirthdayService {
private db: PouchDB;
// para fazermos cache
private birthdays;
constructor() {
}
initDB() {
PouchDB.plugin(cordovaSqlitePlugin);
this.db = new PouchDB('birthday.db', {adapter: 'cordova-sqlite'});
}
}
Talvez, você tenha um erro como o demonstrado abaixo, para o nosso caso este erro pode ser ignorado pois estamos executando a aplicação no desktop e não e um dispositivo móvel.
Salvando o aniversariante
add(birthday) {
return this.db.post(birthday);
}
É só isso que você precisa fazer ;)
Não precisamos escrever SQL de insert, ou fazer qualquer tipo de mapeamento do objeto para que ele seja salvo, só precisamos entender que objeto JSON será salvo como ele foi enviado, se haver lixo, vai ser salvo com ele.
Há duas maneiras de salvar o objeto, usando o metodo post e o método put, a diferença é que com post será gerado um _id para você, enquanto que no put você precisa fazer isto. Dicas como esta, para você escrever código melhor com PouchDB pode ser lidas aqui.
Atualizar e apagar
update(birthday) {
return this.db.put(birthday);
}
delete(birthday) {
return this.db.remove(birthday);
}
Estas operações são igualmente simples :D
details.page.html final ficou assim
<ion-header>
<ion-toolbar>
<ion-title>{{ action }} Birthday</ion-title>
<ion-buttons slot="end" *ngIf="!isNew">
<ion-button (click)="delete()">
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-list>
<ion-item>
<ion-label>Name</ion-label>
<ion-input class="ion-text-end" type="text" [(ngModel)]="birthday.name"></ion-input>
</ion-item>
<ion-item>
<ion-label>Birthday</ion-label>
<ion-datetime displayFormat="MMMM D, YYYY" pickerFormat="MMMM D YYYY" [(ngModel)]="isoDate">
</ion-datetime>
</ion-item>
</ion-list>
<ion-button color="primary" expand="block" (click)="save()">Save</ion-button>
</ion-content>
Para que tudo funcione, vamos alterar o details.page.ts para termos as lógicas corretas de uma olhada na versão final.
@Component({
selector: 'app-details',
templateUrl: './details.page.html',
styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
public birthday: any = {};
public isNew = true;
public action = 'Add';
public isoDate = '';
constructor(private birthdayService: BirthdayService,
private navParams: NavParams,
private modalCtrl: ModalController) {
}
ngOnInit() {
this.birthday = {};
const editBirthday = this.navParams.get('birthday');
if (editBirthday) {
this.birthday = editBirthday;
this.isNew = false;
this.action = 'Edit';
this.isoDate = new Date(this.birthday.date).toISOString().slice(0, 10);
}
}
save() {
this.birthday.date = new Date(this.isoDate);
if (this.isNew) {
this.birthdayService.add(this.birthday)
.catch(console.error.bind(console));
} else {
this.birthdayService.update(this.birthday)
.catch(console.error.bind(console));
}
this.modalCtrl.dismiss(this.birthday);
}
delete() {
this.birthdayService.delete(this.birthday)
.catch(console.error.bind(console));
this.modalCtrl.dismiss(this.birthday);
}
}
Recuperando todos os aniversários
getAll() {
if (!this.birthdays) {
return this.db.allDocs({include_docs: true})
.then(docs => {
// Cada linha tem um objeto .doc, nós queremos apenas um array dos aniversários,
// por isto vamos pegar apenas as informações dentro do .doc, e vamos converter data,
// que não é convertida automaticamente.
this.birthdays = docs.rows.map(row => {
// Dates are not automatically converted from a string.
row.doc.date = new Date(row.doc.date);
return row.doc;
});
// ouve mudanças no banco de dados, se haver executa onDatabaseChange
this.db.changes({live: true, since: 'now', include_docs: true})
.on('change', this.onDatabaseChange);
return this.birthdays;
});
} else {
// Retorna o cache como uma promise
return Promise.resolve(this.birthdays);
}
}
private onDatabaseChange = (change) => {
const index = this.findIndex(this.birthdays, change.id);
const birthday = this.birthdays[index];
if (change.deleted) {
if (birthday) {
this.birthdays.splice(index, 1); // delete
}
} else {
change.doc.date = new Date(change.doc.date);
if (birthday && birthday._id === change.id) {
this.birthdays[index] = change.doc; // update
} else {
this.birthdays.splice(index, 0, change.doc); // insert
}
}
};
// Busca binária, o array é por padrão ordenado por _id
// https://pouchdb.com/2015/02/28/efficiently-managing-ui-state-in-pouchdb.html
private findIndex(array, id) {
let low = 0, high = array.length, mid;
while (low < high) {
mid = (low + high) >>> 1;
array[mid]._id < id ? low = mid + 1 : high = mid;
}
return low;
}
Este código que recupera os aniversariantes provavelmente não é o mais legível/simples que você tenha encontrado, provavelmente você torceu o nariz, mas antes, leia este post do Nolan Lawson, depois analise friamente o código e perceba que ele foi feito para obter melhor performance, em detrimento de alguma possível simplicidade.
Se tratando de dispositivos móveis, preocupar-se com a performance do aparelho do seu publico é necessário, nem todo mundo tem o smartphone highend do momento.
Quando se trata de cache, não existe forma de saber se realmente vale a pena utiliza-lo, você terá de experimentar no seu caso, analise o consumo de memória, processador, rede, e como sua aplicação vai se comportar com o usuário. Ha situações em que o aplicativo força a renderização completa da tela, e isto provoca requisições de informações que possivelmente você tem no cache.
Para chamar o método getAll iremos mudar o construto e a implementação do ngOnInit para:
constructor(private birthdayService: BirthdayService,
private modalCtrl: ModalController,
private platform: Platform,
private zone: NgZone) {
}
ngOnInit() {
this.platform.ready().then(() => {
this.birthdayService.initDB();
this.birthdayService.getAll().then(data => {
this.zone.run(() => {
this.birthdays = data;
});
}).catch(console.error.bind(console));
});
}
Quer saber mais sobre o NgZone? Leia meu outro post que fala sobre otimizações no Angular:
home.page.html final ficou assim
<ion-header>
<ion-toolbar> <!-- Antes utilizavamos <ion-navbar> -->
<ion-title> 🎂 Birthdays 🎉</ion-title>
<ion-buttons slot="end">
<ion-button (click)="showDetail()">
<ion-icon name="add"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list inset="true">
<ion-item *ngFor="let birthday of birthdays" (click)="showDetail(birthday)">
<div slot="start">{{ birthday.name }}</div> <!-- ante era assim: <div item-left>{{ birthday.Name }}</div> ver: https://ionicframework.com/docs/api/item--->
<div slot="end">{{ birthday.date | date:'y/MMMM/dd'}}</div>
</ion-item>
</ion-list>
</ion-content>
Talvez você queira apagar todos os dados durante seus testes, para isto, vá na aba Application e clique no botão Clean Site Data :D
O código completo, pode ser encontrado aqui: