Developing Android services - Phần 2
Bài đăng này đã không được cập nhật trong 9 năm
Developing Android services - Phần 2
Như đã giới thiệu ở bài trước Tôi đã giới thiệu thế nào là một Service trong Android, tạo một service đơn giản và Thực hiện một tác vụ chạy dài sử dụng Service tới các bạn. Bài viết này tôi sẽ tiếp tục với 2 nội dung chính sau đây:
- Làm sao để cải thiện hiệu xuất của một task lặp trong một service
- Làm sao để một activity và một service giao tiếp được với nhau
1. Cải thiện hiệu xuất của một task chạy lặp trong Service
Bên cạnh việc thực hiện một tác vụ chạy dài, bạn cũng có thể thực hiện một tác vụ chạy lặp đi lặp lại trong service. Ví dụ như, Bạn có thể viết một ứng dụng làm đồng hồ báo thức sử dụng Service, service này chạy liên tục dưới dạng backgroud. Trong trường hợp này, Service của bạn có thể phải định kỳ chạy một khố lệch để kiểm tra xem liệu có một lịch trình công việc nào cần thực hiện không và sẽ bật âm thanh báo thức. Để thực hiện được điều này bạn cần dùng đến `Time` class trong service. Hãy cùng tôi xem xét ví dụ sau: Sử dụng Time class trong service
- QuanService.java
package com.example.hoangquan.quanservice;
import android.app.Service;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.IBinder;
import android.util.Log;
import android.widget.Toast;
import java.net.URL;
public class QuanService extends Service {
int counter = 0;
static final int UPDATE_INTERVAL = 1000;
private Timer timer = new Timer();
@Override
public IBinder onBind(Intent arg0) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Toast.makeText(this, “Service Started”, Toast.LENGTH_LONG).show(); doSomethingRepeatedly();
return START_STICKY;
}
private void doSomethingRepeatedly() {
timer.scheduleAtFixedRate( new TimerTask() { public void run()
{
Log.d(“MyService”, String.valueOf(++counter)); } }, 0, UPDATE_INTERVAL);
}
@Override
public void onDestroy()
{
super.onDestroy();
if (timer != null){
timer.cancel();
}
Toast.makeText(this, “Service Destroyed”, Toast.LENGTH_LONG).show();
}
}
Chạy chương trình Nhấn vào nút Start và xem kết quả
01-16 15:12:04.364: DEBUG/QuanService(495): 1
01-16 15:12:05.384: DEBUG/QuanService(495): 2
01-16 15:12:06.386: DEBUG/QuanService(495): 3
01-16 15:12:07.389: DEBUG/QuanService(495): 4
01-16 15:12:08.364: DEBUG/QuanService(495): 5
01-16 15:12:09.427: DEBUG/QuanService(495): 6
01-16 15:12:10.374: DEBUG/QuanService(495): 7
Giải thích ví dụ:
Trong Ví dụ này, Tôi đã tạo ra một Đối tượng kiểu Timer
và gọi Phương thức scheduleAtFixedRate()
trong function doSomethingRepeatedly()
.
trong đó định nghĩa như sau:
private void doSomethingRepeatedly() {
timer.scheduleAtFixedRate(new TimerTask() {
public void run() {
Log.d(“MyService”, String.valueOf(++counter));
}
}, 0, UPDATE_INTERVAL);
}
Bạn thông qua một instance của TimerTask
class để thực hiện phương thức scheduleAtFixedRate()
qua đó bạn thực thi khối lệnh của mình trong phương phương thức run() chạy liên tục. Đối số thứ hai scheduleAtFixedRate()
chỉ định tổng thời gian tính bằng mini giây trước lần thực thi đầu tiên. Tham số thứ ba chỉ định thời gian giữa các hành động tiếp theo.
Trong ví dụ trên, Về cơ bản tôi in ra giá trị đếm được mỗi giây. Service sẽ liên tục in ra giá trị counter
cho đến khi service kết thúc.
@Override
public void onDestroy() {
super.onDestroy();
if (timer != null){
timer.cancel();
}
Toast.makeText(this, “Service Destroyed”, Toast.LENGTH_LONG).show();
}
Đối với phương thức scheduleAtFixedRate()
, Code của bạn sẽ được thực thi cố định với khoảng thời gian cụ thể bất kể bao lâu. Ví dụ, Nếu code ở trong phương thức run()
mất hai giây để hoàn thành thì nhiệm vụ thứ hai sẽ thực hiện ngay sau khi Nhiệm vụ thứ nhất kết thúc. Tương tự như vậy, Nếu bạn để delay
ba giây và nhiệm vụ đó mất hai giây để hoàn thành thì nhiệm vụ thứ 2 phải chờ một giây để bắt đầu hoạt động.
Thực hiện Một Task không đồng bộ trên các Theard riêng biệt sử dụng IntentService
Như bạn đã biết, Một Service sẽ chạy liên tục và có thể chạy lặp đi lặp lại. Vì vậy rất tốn tài nguyên của thiết bị. Do đó bạn phải thực hiện phương thức stopSelf()
để dừng service
của mình khi chắc chắn rằng nó đã hoàn thành xong tất cả nhiệm vụ của nó. Thật không may là đôi khi chính bạn cũng không biết khi nào task được hoàn thành hoặc quên việc stop service
. Để tạo dễ dàng hơn khi tạo ra một service không đồng bộ và chấm dứt nó ngay khi nó được thực hiện bạn có thể sử dụng IntentService
.
IntentService class là một class base của Service để xử lý các yêu cầu không đồng bộ. Nó sẽ được khởi động như một Service bình thường và thực thi các task trong một "Worker thread"
và tự hủy khi nhiệm vụ đó chấm dứt.
Xem xét ví dụ sau đây:
MyIntentService.java
package com.example.hoangquan.quanservice;
import java.net.MalformedURLException;
import java.net.URL;
import android.app.IntentService;
import android.content.Intent;
import android.util.Log;
public class MyIntentService extends IntentService {
public MyIntentService() {
super(“MyIntentServiceName”);
}
@Override
protected void onHandleIntent(Intent intent) {
try {
int result = DownloadFile(new URL(“http://www.amazon.com/somefile.pdf”)); Log.d(“IntentService”, “Downloaded “ + result + “ bytes”);
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
private int DownloadFile(URL url) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} return 100; }
}
AndroidManifest.xml
<?xml version=”1.0” encoding=”utf-8”?>
<manifest xmlns:android=”http://schemas.android.com/apk/res/android”
package=”net.learn2develop.Services”
android:versionCode=”1”
android:versionName=”1.0”>
<application android:icon=”@drawable/icon” android:label=”@string/app_name”>
<activity android:name=”.MainActivity”
android:label=”@string/app_name”>
<intent-filter>
<action android:name=”android.intent.action.MAIN” />
<category android:name=”android.intent.category.LAUNCHER” />
</intent-filter>
</activity>
<service android:name=”.QuanService” />
<service android:name=”.MyIntentService” />
</application>
<uses-sdk android:minSdkVersion=”9” />
<uses-permission android:name=”android.permission.INTERNET”></uses-permission>
</manifest>
MainActivity.java
public class MainActivity extends Activity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
Button btnStart = (Button) findViewById(R.id.btnStartService);
btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
//startService(new Intent(getBaseContext(), QuanService.class));
startService(new Intent(getBaseContext(), MyIntentService.class));
}
});
Button btnStop = (Button) findViewById(R.id.btnStopService);
btnStop.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
stopService(new Intent(getBaseContext(), QuanService.class));
}
});
}
}
Chạy ứng dụng và click nút " Start Service" sau đó xem logCat
bạn sẽ thấy kết quả như sau:
01-17 03:05:21.244: DEBUG/IntentService(692): Downloaded 100 bytes
Giải thích ví dụ:
Bạn cần phải implement một constructor
cho class của mình sau đó gọi supperclass
với tên của IntentService
của bạn:
public MyIntentService() {
super(“MyIntentServiceName”);
}
Sau đó bạn implement phương thức onHandleIntent()
để sử lý các task của bạn như một worker thread
:
@Override
protected void onHandleIntent(Intent intent) {
try {
int result =
DownloadFile(new URL(“http://www.amazon.com/somefile.pdf”));
Log.d(“IntentService”, “Downloaded “ + result + “ bytes”);
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
Trong Phương thức onHandleIntent()
bạn định nghĩa khối lệnh của mình để thực thi các tác vụ mà bạn muốn. Sau khi các task vụ được hoàn thành thì Thread
bị hủy đồng thời service cũng được stop một cách tự động
.
2. Giao tiếp giữa một Service và một Activity.
Thông thường một Service đơn giản sẽ thực thi trong luồng (Thread) riêng của nó, độc lập với activity gọi nó. Điều nàu sẽ không vấn đề gì nếu service của bạn chỉ thực hiện các tác vụ định kỳ mà không cần báo cáo tiến độ hoặc trạng thái của nó. Tuy nhiên, Trong trường hợp bạn muốn tạo ra một service giám sát một địa chỉ cụ thể. Trong trường hợ này, Service của bạn cần phải logs lại một địa chỉ gần địa chỉ bạn muốn giám sát. Lúc đó Service sẽ cần phải giao tiếp với activity để đưa ra thông tin về địa chỉ cho người dùng. Như vậy ta cần phải có một cách nào đó để Service có thể giao tiếp được với activity.
Xem xét ví dụ sau:
MyIntentService.java
package com.example.hoangquan.quanservice;
import java.net.MalformedURLException;
import java.net.URL;
import android.app.IntentService;
import android.content.Intent;
import android.util.Log;
public class MyIntentService extends IntentService {
public MyIntentService() {
super(“MyIntentServiceName”);
}
@Override
protected void onHandleIntent(Intent intent) {
try {
int result = DownloadFile(new URL("http://www.amazon.com/somefile.pdf"));
Log.d(“IntentService”, "Downloaded " + result + “ bytes”);
Intent broadcastIntent = new Intent();
broadcastIntent.setAction(“FILE_DOWNLOADED_ACTION”);
getBaseContext().sendBroadcast(broadcastIntent);
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
private int DownloadFile(URL url) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} return 100;
}
}
MainActivity.java
package com.example.hoangquan.quanservice;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import android.content.IntentFilter;
public class MainActivity extends Activity {
IntentFilter intentFilter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
intentFilter = new IntentFilter();
intentFilter.addAction(“FILE_DOWNLOADED_ACTION”);
registerReceiver(intentReceiver, intentFilter);
Button btnStart = (Button) findViewById(R.id.btnStartService);
btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
startService(new Intent(getBaseContext(),
MyIntentService.class));
}
});
Button btnStop = (Button) findViewById(R.id.btnStopService);
btnStop.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
stopService(new Intent(getBaseContext(), MyService.class));
}
});
}
private BroadcastReceiver intentReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Toast.makeText(getBaseContext(), "File downloaded!",Toast.LENGTH_LONG).show();
}
};
}
Chạy ứng dụng, Nhấn nút start sercive sau đó đợi 5 giây và Toast sẽ hiện lên.
Giải thích ví dụ
Để thông báo cho Activity của bạn biết service đã hoạt động xong trong Android cũng cấp class BroadcastReceiver
để bạn phát sóng tín hiệu cho Activity biết. sử dụng Method sendBroadcast()
:
protected void onHandleIntent(Intent intent) {
try { int result = DownloadFile(new URL("http://www.amazon.com/somefile.pdf"));
Log.d(“IntentService”, “Downloaded “ + result + “ bytes”);
Intent broadcastIntent = new Intent();
broadcastIntent.setAction(“FILE_DOWNLOADED_ACTION”);
getBaseContext().sendBroadcast(broadcastIntent);
} catch (MalformedURLException e) {
e.printStackTrace();
}
}
Action
của Intent mà bạn phát sóng được set “FILE_DOWNLOADED_ACTION”
, Điều này có nghĩa là bất kỳ Activity nào đăng lắng nghe đều được gọi. Do đó, trong file MainActivity.java
bạn đăng ký Phương thức registerReceiver()
từ lớp IntentFilter
để lắng nghe sự kiện mà bạn phát song trước đó:
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
intentFilter = new IntentFilter();
intentFilter.addAction(“FILE_DOWNLOADED_ACTION”);
registerReceiver(intentReceiver, intentFilter);
Button btnStart = (Button) findViewById(R.id.btnStartService);
btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
startService(new Intent(getBaseContext(), MyIntentService.class));
}
}
});
Khi Intent
được nhận nó sẽ gọi đến instance
của class BroadcastReceiver
mà bạn đã định nghĩa:
private BroadcastReceiver intentReceiver = newBroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Toast.makeText(getBaseContext(), "File downloaded!", Toast.LENGTH_LONG).show();
}
};
}
Trong trường hợp này Toast “File downloaded”
sẽ được hiển thị. Tất nhiên trong service của mình bạn cần phải lấy ra dữ liệu mà Service trả về.
Trên đây, Bạn vừa thực hiện phát sóng và lắng nghe một Service từ một Activity. Tuy nhiên , Các ví dụ trên đều đơn giản là đếm thời gian hoặc fix cứng giá trị. Một service trên thực tế phức tạp hơn nhiều và đòi hỏi phải trả lại giá trị chính xác mà service làm được cho bạn.
Giả sử bạn muốn Activity gọi chính xác những gì File được tải về thay vì hardcode
thì bạn cần làm những thứ sau đây:
Đầu tiên bạn gọi Activity
tạo ra Intent với service name
:
Button btnStart = (Button) findViewById(R.id.btnStartService); btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
Intent intent = new Intent(getBaseContext(), MyService.class);
}
});
Sau đó bạn truyền vào mảng các url:
Button btnStart = (Button) findViewById(R.id.btnStartService); btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
Intent intent = new Intent(getBaseContext(),MyService.class);
try {
URL[] urls = new URL[]
{
new URL("http://www.amazon.com/somefiles.pdf"), new
URL("http://www.wrox.com/somefiles.pdf"), new
URL("http://www.google.com/somefiles.pdf"), new
URL("http://www.learn2develop.net/somefiles.pdf")
};
intent.putExtra(“URLs”, urls); }
catch (MalformedURLException e) {
e.printStackTrace();
} startService(intent);
}
});
Cuối cung bạn khởi đông service với intent.
Chú ý rằng Array urls
được assign
cho intent là một Object array
.
Khí service kết thúc bạn cần phải extract
thông qua đối tượng Intent trong phương thức onStartCommand()
TRước tiên chiết xuất dữ liệu bằng cách sử dụng phương thức getExtras()
để trả ra một đối tượng kiểuBundle
. Sau đó sử dụng phương thức get()
để chiết xuất ra mảng URL ra dạng object array. Bởi vì trong Java bạn không thể chuyển trực tiếp một array từ dạng này sang dạng khác, nên bạn cần tạo một vòng lặp và chuyển từng phần tử trong array một. Cuối cùng . bạn thực hiện background
task thông qua phương thức execute()
từng URL.
Đây là một cách để activity của bạn thông qua giá trị của service. Như bạn thấy, nếu bạn có dữ liệu tương đối phức tạp cần thông qua service, bạn cần có một số điều kiện làm việc để chắc chắn rằng dữ liệu được thông qua chính xác. Một cách tốt nhất để thông qua dữ liệu đó là ràng buộc trực tiếp Activity với Service như vậy activity có thể gọi bất kỳ thành viên hoặc phương thức nào trên service.
Xem xét ví dụ sau đây để biết làm thế nào ràng buộc một activity với một service.
QuanService.java
package com.example.hoangquan.quanservice;
import java.net.URL;
import java.util.Timer;
import java.util.TimerTask;
import android.app.Service;
import android.content.Intent;
import android.os.AsyncTask;
import android.util.Log;
import android.widget.Toast;
import android.os.IBinder;
import android.os.Binder;
// Created by HoangQuan on 6/24/2015
public class QuanService extends Service {
int counter = 0;
URL[] urls;
static final int UPDATE_INTERVAL = 1000;
private Timer timer = new Timer();
private final IBinder binder = new MyBinder();
public class MyBinder extends Binder {
QuanService getService() {
return QuanService.this;
}
}
@Override
public IBinder onBind(Intent arg0) {
return binder;
}
@Override
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Toast.makeText(this, "Service Started", Toast.LENGTH_LONG).show();
Object[] objUrls = (Object[]) intent.getExtras().get("URLs");
URL[] urls = new URL[objUrls.length];
for (int i=0; i<objUrls.length-1; i++) {
urls[i] = (URL) objUrls[i];
}
new DoBackgroundTask().execute(urls);
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
Toast.makeText(this, "Service Destroyed", Toast.LENGTH_LONG).show();
}
private int DownloadFile(URL url) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return 100;
}
private class DoBackgroundTask extends AsyncTask<URL, Integer, Long> {
protected Long doInBackground(URL... urls) {
int count = urls.length;
long totalBytesDownloaded = 0;
for (int i = 0; i < count; i++) {
totalBytesDownloaded += DownloadFile(urls[i]);
publishProgress((int) (((i+1) / (float) count) * 100));
}
return totalBytesDownloaded;
}
protected void onProgressUpdate(Integer... progress) {
Log.d(“Downloading files”,
String.valueOf(progress[0]) + “% downloaded”);
Toast.makeText(getBaseContext(),
String.valueOf(progress[0]) + “% downloaded”,
Toast.LENGTH_LONG).show();
}
protected void onPostExecute(Long result) {
Toast.makeText(getBaseContext(),
“Downloaded “ + result + “ bytes”,
Toast.LENGTH_LONG).show();
stopSelf();
}
}
}
MainActivity.java
package com.example.hoangquan.quanservice;
import java.net.MalformedURLException;
import java.net.URL;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import android.os.IBinder;
import android.content.ServiceConnection;
public class MainActivity extends Activity {
IntentFilter intentFilter;
private QuanService serviceBinder;
Intent i;
private ServiceConnection connection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
serviceBinder = ((QuanService.MyBinder)service).getService();
try {
URL[]urls = new URL[] {
new URL("http://www.amazon.com/somefiles.pdf"),
new URL("http://www.wrox.com/somefiles.pdf"),
new URL("http://www.google.com/somefiles.pdf"),
new URL("http://www.learn2develop.net/somefiles.pdf")};
serviceBinder.urls = urls;
} catch (MalformedURLException e) {
e.printStackTrace();
}
startService(i);
}
public void onServiceDisconnected(ComponentName className) {
serviceBinder = null;
}
};
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
intentFilter = new IntentFilter();
intentFilter.addAction("FILE_DOWNLOADED_ACTION");
registerReceiver(intentReceiver, intentFilter);
Button btnStart = (Button) findViewById(R.id.btnStartService);
btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
i = new Intent(MainActivity.this, QuanService.class);
bindService(i, connection, Context.BIND_AUTO_CREATE);
}
});
Button btnStop = (Button) findViewById(R.id.btnStoptService);
btnStop.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
stopService(new Intent(getBaseContext(), QuanService.class));
}
});
}
}
Nhấn F11 để chạy ứng dụng. lúc đó bạn sẽ thấy service chạy bình thường. và Toast "File Downloaded"
được hiển thị.
Giải thích:
Để bind
một activity
tới một service
bạn cần khai báo một class trong service
extends từ class Binder
. Trong class đó bạn inplement phương thức getService()
để trả ra instance của service.
public class MyBinder extends Binder {
QuanService getService() {
return QuanService.this;
}
}
Sau đó tạo ra một instance
của class MyBinder
:
private final IBinder binder = new MyBinder();
cần modify
phương thức onBind()
để nó trả ra instance của class Mybinder
:
@Override
public IBinder onBind(Intent arg0) {
//return null;
return binder;
}
Trong phương thức onStartCommand()
bạn gọi execute()
sử dụng mảng urls,
public int onStartCommand(Intent intent, int flags, int startId) {
Toast.makeText(this, "Service Started", Toast.LENGTH_LONG).show();
new DoBackgroundTask().execute(urls);
return START_STICKY;
}
Trong MainActivity.java
, Đầu tiên bạn khai báo một instance của service và một Intent object:
private MyService serviceBinder;
Intent i;
Để giám sát được trạng thái của service bạn cần tạo ra một instance của class ServiceConnection
:
private ServiceConnection connection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
serviceBinder = ((QuanService.MyBinder)service).getService();
try {
URL[]urls = new URL[] {
new URL("http://www.amazon.com/somefiles.pdf"),
new URL("http://www.wrox.com/somefiles.pdf"),
new URL("http://www.google.com/somefiles.pdf"),
new URL("http://www.learn2develop.net/somefiles.pdf")};
serviceBinder.urls = urls;
} catch (MalformedURLException e) {
e.printStackTrace();
}
startService(i);
}
public void onServiceDisconnected(ComponentName className) {
serviceBinder = null;
}
};
Trong đó bạn implement
2 phương thức onServiceConnected()
và onServiceDisconnected()
. Hai phương thức này được gọi khi Activity được kết nối với service hoặc ngược lại.
Trong phương thức onServiceConnected()
, Mội khi activity và service được kết nối bạn có thể gọi service thông qua phương thức getService()
, qua đó bạn lấy được các đối số được thiết lập trong service sau đó gán nó vào môt serviceBinder object
. Thông qua object serviceBinder này ta có thể gọi bất kỳ thành viên hoặc phương thức nào trong service. Trong trường hợp này bạn truyền vào một mảng các url để download nên bạn cần tách riêng mỗi url thành một task riêng để service thực hiện và gọi :
startService(i);
để thực hiện download.
trước khi thực hiện service, Bạn hãy bind activity tới service tương ứng bằng cách để người dùng kích vào nút start service:
Button btnStart = (Button) findViewById(R.id.btnStartService);
btnStart.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
i = new Intent(MainActivity.this, QuanService.class);
bindService(i, connection, Context.BIND_AUTO_CREATE);
}
});
Thông qua phương thức bindService()
bạn có thể kết nối activity tới service bằng cách chỉ ra 3 đối số tương ứng với inten
t, ServiceConnection
và một flag
để chỉ ra cách mà service bị ràng buộc với activity (trong trường hợp này là ràng buộc tự động BIND_AUTO_CREATE
).
Bạn có thể tham khảo source code của tôi tại: https://github.com/HoangQuan/QuanService
Xin Cảm ơn!
All rights reserved