1. BLE 통신 개념
1) 개념 정리
- BLE 란? :
- Master, Client : 앱 // Slave, Server : 블루투스 기기
- 통신과정 : 스캔 -> 연결 -> 통신
- 연산 종류 : Read, Write, Notification, Indication
- Notification : 옵저버 패턴으로 작동, 기기에 옵저버를 심어놓은 후 상태가 변화했을 때 앱의 메소드로 콜백함
- BluetoothGatt 구조 : 여러개의 BluetoothService 를 가짐 -> 각 BluetoothService는 여러개의 BluetoothCharacteristic을 가짐 -> 각 BluetoothCharacteristic은 여러개의 BluetoothDescriptor 를 가짐
- UUID개념 :각 BluetoothService, BluetoothCharacterstic, BluetoothDescriptor는 고유한 아이디인 uuid를 가진다
- 좀 더 구체적으로 UUID를 알기 위해서 예시를 들면,
캐릭터1 : 0000ff01-0000-1000-8000-00805f9b34fb READ WRITE (디바이스 정보)
캐릭터2 : 0000ff02-0000-1000-8000-00805f9b34fb 디바이스 이름
캐릭터3 : 0000ff03-0000-1000-8000-00805f9b34fb Notification
- 기본 통신개념 : 통신은 바이트배열을 보내고 받는다
2. SCAN -> CONNECT -> READ/WRITE 과정
1) Scan하기 위해 블루투스 ON 상태확인과 위치권한이 필요
Intent intent = new Intent(MainActivity.this, DeviceListBTActivity.class);
startActivityForResult(intent, DEVCIE_LIST_BT_ACTIVITY);
2) Scan Activity
public class DeviceListBTActivity extends Activity {
private static final int REQUEST_ENABLE_BT = 99;
private BluetoothAdapter mBluetoothAdapter;
private TextView mEmptyList;
public static final String TAG = "DeviceListBTActivity";
ArrayList<BluetoothDevice> deviceList = new ArrayList<>();
ArrayList<Integer> rssiList = new ArrayList<>();
private BTAdapter deviceAdapter;
private static final long SCAN_PERIOD = 10000; //scanning for 10 seconds
private Handler mHandler;
private boolean mScanning;
ListView listView;
private BluetoothLeScanner scanner;
private Button cancelButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
checkPermissionBluetooth();
}
@Override
protected void onResume() {
super.onResume();
//등록됐을때 맥주소를 DB에 저장해놓고, 저장플래그도 설정하고
}
private void checkPermissionBluetooth() {
/**
* 로직 : 1. 블루투스 검사
* 1-1. 블루투스 연결 된 경우 : 2 이동
* 1-2. 블루투스연결 안된 경우 : 설정 창 띄움
* 1-2-1. 설정에서 허용한경우 : 2 이동
* 1-2-2. 설정에서 허용하지 않은 경우 : finish
* 2. 위치 권한 검사
* 2-1. 위치 권한 허용 된 경우 : 스캔 시작
* 2-2. 위치 권한 허용 안된 경우 : 허용창 띄움
* 2-2-1. 허용창에서 허용한 경우 : 스캔 시작
* 2-2-2. 허용창에서 허용하지 않은 경우 : 설정창 이동 창 띄움
* 2-2-2-1. 설정창에서 허용하지 않은 경우 : finish
* 2-2-2-2. 설정창에서 허용한 경우 : 스캔 시작
*
*/
final BluetoothManager bluetoothManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
mBluetoothAdapter = bluetoothManager.getAdapter();
if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)) {
Toast.makeText(this, "이 기기는 블루투스를 지원하지 않습니다", Toast.LENGTH_SHORT).show();
finish(); //이런 예외상황이 있을 수 있나. 어차피 최소버전은 21인데
}
if (mBluetoothAdapter == null || !mBluetoothAdapter.isEnabled()) {
Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT);
} else {
TedPermission.with(this)
.setPermissionListener(new PermissionListener() {
@Override
public void onPermissionGranted() {
Log.d(TAG, "onPermissionGranted: 권한 설정 완료");
initView();
}
@Override
public void onPermissionDenied(ArrayList<String> deniedPermissions) {
Toast.makeText(DeviceListBTActivity.this, "[위치]권한을 허용하신 후 다시 이용해 주세요", Toast.LENGTH_SHORT).show();
finish();
}
}) // 퍼미션에 대한 콜백 작업 (아래)
.setRationaleMessage("블루투스 기기를 검색하기 위해 [위치]권한을 허용하셔야 합니다")
.setGotoSettingButton(true)
.setPermissions(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
.check();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_ENABLE_BT) {
if (resultCode == RESULT_OK) {
checkPermissionBluetooth();
} else {
Toast.makeText(DeviceListBTActivity.this, "블루투스 기능을 활성화한 후 다시 실행해주세요", Toast.LENGTH_SHORT).show();
finish();
}
}
}
private void initView() {
/* Initialize device list container */
setContentView(R.layout.activity_device_list_bt);
android.view.WindowManager.LayoutParams layoutParams = this.getWindow().getAttributes();
layoutParams.gravity = Gravity.TOP;
layoutParams.y = 200;
mHandler = new Handler();
deviceAdapter = new BTAdapter(DeviceListBTActivity.this, deviceList);
listView = findViewById(R.id.new_devices);
listView.setAdapter(deviceAdapter);
listView.setOnItemClickListener(deviceClickListener);
cancelButton = findViewById(R.id.btn_cancel);
mEmptyList = (TextView) findViewById(R.id.empty);
cancelButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mScanning == false) scanLeDevice(true);
else finish();
}
});
scanLeDevice(true);
}
/**
* 킷캣 , 롤리팝 분기 처리
*
* @param enable
*/
@SuppressLint("NewApi")
private void scanLeDevice(final boolean enable) {
if (enable) {
BluetoothModule.getInstance().disconnect();
//롤리팝 기준으로 분기 처리 (킷캣 별도 처리)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
scanner.stopScan(scanCallback);
mScanning = false;
cancelButton.setText("스캔시작");
}
}, SCAN_PERIOD);
mScanning = true;
scanner = mBluetoothAdapter.getBluetoothLeScanner();
List<ScanFilter> scanFilters = new ArrayList<>();
ScanFilter scanFilter = new ScanFilter.Builder()
.setServiceUuid(ParcelUuid.fromString())
.build();
scanFilters.add(scanFilter);
ScanSettings scanSettings = new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC).build();
scanner.startScan(scanFilters, scanSettings, scanCallback);
cancelButton.setText("취소");
} else {
mHandler.postDelayed(new Runnable() {
@Override
public void run() {
mScanning = false;
mBluetoothAdapter.stopLeScan(scanLeCallBack);
cancelButton.setText("스캔시작");
}
}, SCAN_PERIOD);
mScanning = true;
mBluetoothAdapter.startLeScan(scanLeCallBack);
cancelButton.setText("취소");
}
}
}
public BluetoothAdapter.LeScanCallback scanLeCallBack = new BluetoothAdapter.LeScanCallback() {
@Override
public void onLeScan(final BluetoothDevice device, final int rssi, byte[] scanRecord) {
runOnUiThread(new Runnable() {
@Override
public void run() {
addDevice(device, rssi);
}
});
}
}
};
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public ScanCallback scanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, final ScanResult result) {
super.onScanResult(callbackType, result);
if (deviceList.contains(result.getDevice())) //중복 검색 제외
return;
runOnUiThread(new Runnable() {
@Override
public void run() {
addDevice(result.getDevice(), result.getRssi());
}
});
}
@Override
public void onBatchScanResults(List<ScanResult> results) {
super.onBatchScanResults(results);
Log.d(TAG, "onBatchScanResults: " + results.toString());
}
@Override
public void onScanFailed(int errorCode) {
super.onScanFailed(errorCode);
}
};
private void addDevice(BluetoothDevice device, int rssi) {
rssiList.add(rssi);
deviceList.add(device);
mEmptyList.setVisibility(View.GONE);
deviceAdapter.notifyDataSetChanged();
}
private AdapterView.OnItemClickListener deviceClickListener = new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Intent result = new Intent();
result.putExtra(BluetoothDevice.EXTRA_DEVICE, deviceList.get(position).getAddress());
setResult(Activity.RESULT_OK, result);
finish();
}
};
class BTAdapter extends BaseAdapter {
Context context;
List<BluetoothDevice> devices;
LayoutInflater inflater;
BTAdapter(Context context, List<BluetoothDevice> devices) {
this.context = context;
inflater = LayoutInflater.from(context);
this.devices = devices;
}
@Override
public int getCount() {
return devices.size();
}
@Override
public Object getItem(int position) {
return devices.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewGroup vg;
if (convertView != null) {
vg = (ViewGroup) convertView;
} else {
vg = (ViewGroup) inflater.inflate(R.layout.device_bt_element, null);
}
BluetoothDevice device = devices.get(position);
final TextView tvadd = vg.findViewById(R.id.address);
final TextView tvname = vg.findViewById(R.id.name);
final TextView tvpaired = vg.findViewById(R.id.paired);
final TextView tvrssi = vg.findViewById(R.id.rssi);
tvrssi.setVisibility(View.VISIBLE);
tvrssi.setText(rssiList.get(position).toString());
tvname.setText(device.getName());
tvadd.setText(device.getAddress());
tvname.setTextColor(Color.BLACK);
tvadd.setTextColor(Color.BLACK);
tvpaired.setTextColor(Color.GRAY);
tvrssi.setTextColor(Color.BLACK);
tvrssi.setVisibility(View.VISIBLE);
if (device.getBondState() == BluetoothDevice.BOND_BONDED) {
Log.i(TAG, "device::" + device.getName());
tvpaired.setText("paired");
tvpaired.setVisibility(View.VISIBLE);
} else {
tvpaired.setVisibility(View.GONE);
}
return vg;
}
}
protected void onPause() {
super.onPause();
}
@Override
public void onStop() {
super.onStop();
}
@Override
protected void onDestroy() {
super.onDestroy();
}
}
해당 리스트를 선택하면 맥주소를 메인액티비티로 리턴해준다
3) Connect
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == DEVICE_LIST_BT_ACTIVITY) {
if (resultCode == RESULT_OK) {
//블루스트 리스트를 선택했을때
String deviceAddress = data.getStringExtra(BluetoothDevice.EXTRA_DEVICE);
LoadingDialog.showLoading(this);
bluetoothModule.gattConnect(deviceAddress, new BluetoothModule.BluetoothConnectImpl() {
@Override
public void onSuccessConnect(BluetoothDevice device) {
Log.i(TAG, "onSuccessConnect: 연결완료");
Toast.makeText(SettingActivity.this, "연결완료", Toast.LENGTH_SHORT).show();
LoadingDialog.hideLoading();
binding.txtBt.setText(device.getName());
}
@Override
public void onFailed() {
Log.i(TAG, "onFailed: 연결실패, 다시 연결중....");
Toast.makeText(SettingActivity.this, "연결실패, 다시 연결중", Toast.LENGTH_SHORT).show();
}
});
}
}
}
private BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
final Handler handler = new Handler(Looper.getMainLooper());
/**
* 연결상태가 변화 할때 마다 (연결, 끊김) 호출
*/
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
Log.d(TAG, "onConnectionStateChange: " + status + " " + newState);
if (newState == BluetoothProfile.STATE_CONNECTED) {
bluetoothGatt.discoverServices(); // onServicesDiscovered() 호출 (서비스 연결 위해 꼭 필요)
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Toast.makeText(context, "연결이 끊어졌습니다\n다시 연결중입니다기다려주세요", Toast.LENGTH_SHORT).show();
}
}
/**
* 서비스 연결 후 ( Notification 설정 ) cf) setCharacteristicNotification 까지만 해도 Notification이 되지만 이 메소드의 콜백을 받지 못한다
* (setCharacteristicNotification이 비동기로 완료되기 전에 통신을 한다면 에러가 난다) -> writeDescriptor가 완료 된 순간부터 통신이 가능하다
*/
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
BluetoothGattCharacteristic ch = gatt.getService(SampleApplication.SERIVCE).getCharacteristic(SampleApplication.CHARACTERISTIC_NOTY);
gatt.setCharacteristicNotification(ch, true);
BluetoothGattDescriptor descriptor = ch.getDescriptor(SampleApplication.CCCD);
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
bluetoothGatt.writeDescriptor(descriptor);
BluetoothGattService service = bluetoothGatt.getService(SampleApplication.SERIVCE);
writeGattCharacteristic = service.getCharacteristic(SampleApplication.CHARACTERISTIC_READ_WRITE);
SharedPreferences pref = Properties.getSharedPreferences(context);
pref.edit().putString(Properties.CONNECTED_BT_ADDRESS, gatt.getDevice().getAddress()).apply();
pref.edit().putString(Properties.CONNECTED_BT_NAME, gatt.getDevice().getName()).apply();
}
}
@Override
public void onCharacteristicRead(final BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
handler.post(new Runnable() {
@Override
public void run() {
btConnectCallback.onSuccessConnect(gatt.getDevice()); // 통신 준비 완료
}
});
}
@Override
public void onDescriptorWrite(final BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
handler.post(new Runnable() {
@Override
public void run() {
btConnectCallback.onSuccessConnect(gatt.getDevice()); // 통신 준비 완료
}
});
}
/**
* 가장 중요한 메소드, ble 기기의 값을 받아온다
*/
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
handler.post(new Runnable() {
@Override
public void run() {
String value = characteristic.getStringValue(1);
value = value.replaceAll(" ", "");
value = value.substring(0, value.length() - 1);
try {
Log.d(TAG, "run: " + value);
btWriteCallback.onSuccessWrite(0, value);
} catch (IOException e) {
e.printStackTrace();
btWriteCallback.onFailed(e);
}
}
});
}
};
bluetoothGatt.discoverServices();
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
}
이후 통신 가능
4) Read,Write
BluetoothGattService service = bluetoothGatt.getService(SERVICE_UUID);
BluetoothGattCharacteristic ch = service.getCharacteristic(READ_WRITE_UUID);
ch.setValue("<AG>");bluetoothGatt.readCharacteristic(ch);
( DiscoveryService 할 때 기본적으로 저장되어있음 ) (찾을 수 있다)
*기본적으로 서비스 안에 READWRITE 캐릭터가 내제돼 있다
public void onCharacteristicRead(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, int status) {
Log.d(TAG, "onCharacteristicRead: .리드 된거야 " + characteristic.getStringValue(0));
}
로 값을 받을 수 있다.
3. Notification ★★★
-설정-
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
System.out.println("서비스 발견, status : " + status + "서비스 검색 시작");
if (status == BluetoothGatt.GATT_SUCCESS) {
BluetoothGattCharacteristic ch = gatt.getService(SERIVCE_UUID).getCharacteristic(SERIVCE_NOTY_UUID);
gatt.setCharacteristicNotification(ch, true);
} else {
}
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
runOnUiThread(new Runnable() {
@Override
public void run() {
Log.d(TAG, "onCharacteristicChanged: 변화 감지 했습니다");
Log.d(TAG, "onCharacteristicChanged: " + characteristic.getStringValue(0));
}
});
}
이렇게 받을 수 있다. 블루투스 기기가 연속적으로 데이터를 보내면 이 메소드에서 모두 받을 수 있다
모듈화 -싱글톤 패턴
블루투스 모듈public class BluetoothModule {
//BluetoothGatt 객체로 Connect 해주고, writeCharacter 해주는 클래스
public static final String TAG = "BluetoothModule";
private BluetoothGatt bluetoothGatt;
private BluetoothConnectImpl btConnectCallback;
private BluetoothWriteImpl btWriteCallback;
private BluetoothGattCharacteristic writeGattCharacteristic;
private Context context;
BluetoothModule() {
}
private static class BluetoothModuleHolder {
private static final BluetoothModule instance = new BluetoothModule();
}
public static BluetoothModule getInstance() {
return BluetoothModuleHolder.instance;
}
public boolean isConnected() {
return bluetoothGatt != null && bluetoothGatt.connect();
}
public BluetoothGatt getGatt() {
return bluetoothGatt;
}
public void disconnect() {
if (isConnected()) {
bluetoothGatt.disconnect();
}
}
/**
* 맥주소를 받아서 connect
*/
public void gattConnect(String macAddress, BluetoothConnectImpl btConnectCallback) {
this.btConnectCallback = btConnectCallback;
context = SampleApplication.instance;
final BluetoothManager bm = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
BluetoothAdapter bluetoothAdapter = bm.getAdapter();
BluetoothDevice device = bluetoothAdapter.getRemoteDevice(macAddress);
bluetoothGatt = device.connectGatt(context, true, gattCallback);
}
/**
* 프로토콜 보내기 write 를 하고 ble장치로부터 값을 받으면 onCharacteristicChanged() 메소드가 호출 된다
*/
public void sendProtocol(String protocol, BluetoothWriteImpl btWriteCallback) {
if (isConnected()) {
this.btWriteCallback = btWriteCallback;
protocol = "<" + protocol.toUpperCase() + ">";
writeGattCharacteristic.setValue(protocol);
bluetoothGatt.writeCharacteristic(writeGattCharacteristic);
}
}
/**
* 블루투스 콜백
*/
private BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
final Handler handler = new Handler(Looper.getMainLooper());
/**
* 연결상태가 변화 할때 마다 (연결, 끊김) 호출
*/
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
Log.d(TAG, "onConnectionStateChange: " + status + " " + newState);
if (newState == BluetoothProfile.STATE_CONNECTED) {
bluetoothGatt.discoverServices(); // onServicesDiscovered() 호출 (서비스 연결 위해 꼭 필요)
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
Toast.makeText(context, "연결이 끊어졌습니다\n다시 연결중입니다기다려주세요", Toast.LENGTH_SHORT).show();
}
}
/**
* 서비스 연결 후 ( Notification 설정 ) cf) setCharacteristicNotification 까지만 해도 Notification이 되지만 이 메소드의 콜백을 받지 못한다
* (setCharacteristicNotification이 비동기로 완료되기 전에 통신을 한다면 에러가 난다) -> writeDescriptor가 완료 된 순간부터 통신이 가능하다
*/
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status == BluetoothGatt.GATT_SUCCESS) {
BluetoothGattCharacteristic ch = gatt.getService(SampleApplication.SERIVCE).getCharacteristic(SampleApplication.CHARACTERISTIC_NOTY);
gatt.setCharacteristicNotification(ch, true);
BluetoothGattDescriptor descriptor = ch.getDescriptor(SampleApplication.CCCD);
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
bluetoothGatt.writeDescriptor(descriptor);
BluetoothGattService service = bluetoothGatt.getService(SampleApplication.SERIVCE);
writeGattCharacteristic = service.getCharacteristic(SampleApplication.CHARACTERISTIC_READ_WRITE);
SharedPreferences pref = Properties.getSharedPreferences(context);
pref.edit().putString(Properties.CONNECTED_BT_ADDRESS, gatt.getDevice().getAddress()).apply();
pref.edit().putString(Properties.CONNECTED_BT_NAME, gatt.getDevice().getName()).apply();
}
}
@Override
public void onCharacteristicRead(final BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
handler.post(new Runnable() {
@Override
public void run() {
btConnectCallback.onSuccessConnect(gatt.getDevice()); // 통신 준비 완료
}
});
}
@Override
public void onDescriptorWrite(final BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
handler.post(new Runnable() {
@Override
public void run() {
btConnectCallback.onSuccessConnect(gatt.getDevice()); // 통신 준비 완료
}
});
}
/**
* 가장 중요한 메소드, ble 기기의 값을 받아온다
*/
@Override
public void onCharacteristicChanged(BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
handler.post(new Runnable() {
@Override
public void run() {
String value = characteristic.getStringValue(1);
value = value.replaceAll(" ", "");
value = value.substring(0, value.length() - 1);
try {
Log.d(TAG, "run: " + value);
btWriteCallback.onSuccessWrite(0, value);
} catch (IOException e) {
e.printStackTrace();
btWriteCallback.onFailed(e);
}
}
});
}
};
public interface BluetoothConnectImpl {
void onSuccessConnect(BluetoothDevice device);
void onFailed();
}
public interface BluetoothWriteImpl {
void onSuccessWrite(int status, String data) throws IOException;
void onFailed(Exception e);
}
}
어려웠던 점 : 블루투스는 연결할때 context 를 필요로 한다, 하지만 싱글톤으로 작동하려면 context에 종속되지
않아야하는데 context를 필요하니까 모순같은 상황이였다.
그래서 BaseAppliaction(Application 을 상속받은 클래스)의 context를 static 으로 선언후 그 context 를 사용
public static BaseApplication instance = null;
@Override
public void onCreate() {
super.onCreate();
instance = this;
}
context = BaseApplication.instance;
3. 통신
BluetoothModule bluetoothModule = BluetoothModule.getInstance();
bluetoothModule.sendProtocol("AG", new BluetoothModule.BluetoothWriteImpl() {
@Override
public void onSuccessWrite(int status, String data) throws IOException {
//TODO
}
@Override
public void onFailed(Exception e) {
System.out.println("알람데이터 못가져옴");
LoadingDialog.hideLoading();
}
});
참고자료 : https://blog-kr.zoyi.co/bluetooth-low-energy-ble/
https://medium.com/@avigezerit/bluetooth-low-energy-on-android-22bc7310387a
https://developer.android.com/guide/topics/connectivity/bluetooth-le
'Android' 카테고리의 다른 글
Rxjava + MVVM + databinding (0) | 2018.08.21 |
---|---|
리사이클러뷰 리스트 바인딩 문제 (0) | 2018.07.27 |
GridLayoutManager + Spacing (0) | 2017.11.20 |
두번 누르면 종료 (0) | 2017.11.20 |
CustomTitlebar 재활용 (0) | 2017.11.20 |