Flutter로 ios, android 앱 한번 만들어보자. [Locker - 나만의 링크, 사진, 메모 보관함]
안녕하세요 Loker입니다. 😄
락커(Locker)는 링크와 사진, 간단한 메모를 보관하기 위해 만들었습니다. 자주보거나 나중에 보고 싶은 링크, 사용할 기프티콘과 같은 캡쳐, 추억이 있는 사진이나 메모를 한곳에 보관하여 필요할때 검색을 통해 쉽게 찾아볼 수 있는 앱이며, 저장한 내용들은 별도의 서버에 저장되지 않고 개인의 휴대폰 디바이스에만 저장됩니다. 저장한 컨텐츠는 공유 기능을 통해 메시지 플랫폼에 전달하여 공유할 수 있는 기능도 있습니다.
Flutter를 활용하여 Locker앱 개발을 처음 해보면서 앱스토어, 플레이스토어에 배포 하기까지 기능적으로 고민했던 내용을 기록해 두고자 작성했습니다. 😆
앱을 만들기에 앞서 크게 몇 가지 조건이 있었습니다.
- 월급쟁이 개발자이기에 무자본으로 할 수 있어야 한다. (서버 사용 X)
- 서버를 사용하지 않기 때문에 Local Storage를 사용하는 DB가 있어야 한다.
- UI를 어려워하는 백엔드 개발자이기 때문에 UI를 쉽게 할 수 있어야 한다.
- IOS, Android 를 동시에 코딩할 수 있는 언어로 사용해야 한다.
- 허접하더라도 배포까지 빠르게 한 사이클을 돌아봐야 한다.
GPT에게 물으니 Flutter가 적합하다고 느껴져 바로 Flutter 개발 환경을 세팅하고 생각한 기능을 하나씩 구현해 보기로 했습니다. 😄
0. 준비물 🚥
Flutter로 IOS, Android를 개발하고 배포하려면 우선 3가지의 개발 툴이 필요해요.
- Xcode
- Android Studio <-- 이건 사실 쓰진 않았습니다 .ㅎㅎ
- Intellij <-- 다른 걸로 대체해도 됩니다.
1. Sqfilte 💿
Locker는 외부의 서버를 두고 있지 않고 사용자의 기기 디바이스에만 저장해두고 있습니다.그걸 가능하기 위해서 sqfilte를 사용하여 DB로 쓰고 있습니다.
class SqlDatabase {
static final SqlDatabase instance = SqlDatabase._instance();
Database? _database;
SqlDatabase._instance() {
_initDatabase();
}
factory SqlDatabase() {
return instance;
}
Future<Database> get database async {
if (_database != null) return _database!;
await _initDatabase();
return _database!;
}
Future<void> _initDatabase() async {
var databasePath = await getDatabasesPath(); // android, ios 각 플랫폼에 맞는 database 위치를 전달해준다.
String path = join(databasePath, 'locker.db');
_database = await openDatabase(path,
version: 1, onCreate: _databaseCreate); // version은 스키마를 버전관리 해준다.
}
void _databaseCreate(Database db, int version) async {
await db.execute('''
create table ${LinkEntity.tableName} (
${LinkFields.id} integer primary key autoincrement,
${LinkFields.link} text not null,
${LinkFields.title} text null,
${LinkFields.description} text null,
${LinkFields.thumbnail} text not null,
${LinkFields.tag} text null,
${LinkFields.bookmark} Integer not null default 0,
${LinkFields.createdAt} text not null
)
''');
............
}
void closeDatabase() async {
if (_database != null) await _database!.close();
}
}
별도의 SqlDataBase라는 dart 파일을 만들어 관리하고 있어요.
SqlDatabase 파일의 내용은 DB의 초기화 관련된 내용으로 platform(ios, android) 별로 패스를 가지고 오도록 설정을 하고, db 파일 명을 설정하게 됩니다.
그 후에 version을 관리할 수 있는 부분도 있어요. 저 부분은 아직 저도 활용은 해보진 못 했는데요. 제가 이해한 바로는 version을 통해 테이블의 스키마를 관리하는 걸로 이해했습니다.
...
void main() {
SqlDatabase();
});
}
...
main.dart의 생성자에 DB 관련한 초기화 작업을 연결해 주면 앱 시작 시 테이블을 생성하도록 연결되게 됩니다.
class LinkFields {
static final String id = '_id';
static final String link = 'link';
static final String title = 'title';
static final String description = 'description';
static final String thumbnail = 'thumbnail';
static final String tag = 'tag';
static final String bookmark = 'bookmark';
static final String createdAt = 'createdAt';
}
class LinkEntity {
static String tableName = 'link';
final int? id;
final String link;
final String? title;
final String? description;
final String? thumbnail;
final String? tag;
final int bookmark;
final DateTime createdAt;
const LinkEntity(
{this.id, // null 가능 autoincrement
required this.link,
this.thumbnail,
this.title,
this.description,
this.tag,
required this.bookmark,
required this.createdAt});
LinkEntity.dart 파일을 생성해 테이블의 정보를 미리 정의해서 사용했어요. LinkFileds라는 클래스도 별도로 생성해서 Link 테이블의 컬럼을 관리하도록 했습니다. 안 그러면 여기저기 조회나 저장할 때 칼럼명을 하드코딩해야 되는 문제가 있더라고요.
2. 클릭 이벤트 💥
GestureDetector(
onLongPress: () async {
isLongPressEvent = true;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LinkDetailView(link: linkEntity)))
});
},
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GoLink(link: linkEntity),
),
);
update();
},
)
링크, 사진 컨텐츠에는 '짧게 클릭(view)', '길게 클릭(상세페이지)' 이벤트가 있습니다. 처음 이렇게 구현을 해보니 '길게 클릭'을 하는 동작에 '짧게 클릭' 하는 이벤트도 같이 실행이 되는 문제가 있었습니다.
그래서 flag를 두어 제어를 하는 방법으로 처리를 했어요. 기본적으로 flag 값은 false로 세팅하고 '길게 클릭' 할 경우 flag 값을 true로 바뀌게 말이죠.
아래는 정상동작을 위해 수정한 코드예요.!
상단에 멤버변수로 "isLongPressEvent"를 false로 초기화하고 길게 누를 경우 true로 세팅을 하도록 state를 변경해 주었어요.
'짧게 클릭' 할 때는 "isLongPressEvent"가 true면 동작하지 않도록 분기처리를 해서 제어하도록 수정해 주었습니다.😌
GestureDetector(
onLongPress: () async {
isLongPressEvent = true;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LinkDetailView(link: linkEntity)))
.then((value) => {
setState(() {
isLongPressEvent = false;
}),
});
},
onTap: () async {
if (!isLongPressEvent) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GoLink(link: linkEntity),
),
);
update();
}
},
)
3. 웹 페이지의 썸네일, 제목, 설명 추출하기 📸
보관할 링크의 URL을 저장하면 자동으로 해당 페이지의 썸네일과 제목, 설명을 추출해서 위에 설정한 Link 테이블에 저 정하게 하고 싶었어요. 그러기 위해서 아래와 같이 처리를 했습니다. http 플러그인을 활용하여 Uri의 resource를 확인하여 관련 3개 항목을 추출하도록 했어요.
class ExtractLinkMeta {
static Future<ExtractData> extract(String uri) async {
final response = await http.get(Uri.parse(uri), headers: {
}).timeout(const Duration(seconds: 5));
dom.Document document = parse.parse(response.body);
String? title = document.head
?.querySelector("meta[property='og:title']")
?.attributes['content'] ??
"No Title";
String? description = document.head
?.querySelector("meta[property='og:description']")
?.attributes['content'] ??
"No Description";
String? thumbnail = document.head
?.querySelector("meta[property='og:image']")
?.attributes['content'] ??
"";
return ExtractData(
title: title, description: description, thumbnail: thumbnail);
}
}
class ExtractData {
final String? title;
final String? description;
final String? thumbnail;
const ExtractData({this.title, this.description, this.thumbnail});
}
코드를 보시면 아시겠지만, 추출하려는 property가 정의가 안되어있는 페이지들도 있어 빈 String으로 가지고 오도록 방어로직을 넣어두었습니다. 그 후에 앱에서 썸네일에 접근할 경우 없을 때는 로컬에 저장한 이미지를 불러오도록 처리를 해주었어요.
4. 사진 저장 📸
사진 저장에서 첫 번째로 해야 할 건 사용자 디바이스의 접근권한을 얻어야 사진첩에 접근이 가능해요.
- 사진 등록 시 권한 확인 하기
- 권한이 없을 경우 alert을 통해 권한을 설정할 수 있도록 전달하기
- 권한이 있을 경우 ImagePicker를 통해 사진첩의 사진을 가져오기.
// 사진첩 접근권한 확인
Future<bool> _requestPermission() async {
var status = await Permission.photos.request();
if (status.isGranted) {
return true; // 이미 권한이 부여된 경우
} else {
// 권한 요청
var result = await Permission.photos.request();
// 요청 결과 확인
if (result == PermissionStatus.granted) {
return true; // 권한이 부여된 경우
} else {
return false; // 권한이 거부된 경우
}
}
return true;
}
// 사진등록 시 권한 확인 후 이상 없을 경우 ImagePicker로 사진 가져오기
Future<void> _getImageFromGallery() async {
// 권한 체크
if (!(await _requestPermission())) {
showPermissionDialog(context);
return;
}
final imagePicker = ImagePicker();
final pickedFile = await imagePicker.pickImage(source: ImageSource.gallery);
if (pickedFile != null) {
// 사진을 정상적으로 가져왔을때의 액션을 여기에 작성하면 됩니다.
}
}
IOS의 경우 info.plist 파일에 권한을 설정을 하도록 되어있는데요. 앱 배포 시 권한요청 하는 문구도 심사의 대상이더라고요. 사진첩 접근을 해서 그 사진을 어떻게 사용하는지를 사용자에게 정확히 고지를 해야 앱 심사가 통과가 되었어요. 저는 아래와 같이 문구를 설정했습니다.
<key>NSPhotoLibraryUsageDescription</key>
<string>Allow Photo access to add photos to your locker.</string>
5. 사진 저장의 경로 문제 👿
Locker를 만들어 보면서 제일 난감한 문제였던 거 같아요..😂
우선 처음으로는 ImagePicker를 통해 이미지를 가져와 바이너리로 변환하고 테이블에 String 형식으로 저장을 해두었어요. 그 후에 목록 조회 할 때 다시 바이너리를 사진 객체로 변환해서 목록을 뿌려주게 했습니다.
정상적으로 잘 저장되고 잘 가져와서 뿌려주긴 하더라고요.
근데 성능상 문제가 있었습니다. 아무래도 저장된 사진을 바이너리 형식으로 해두고 그걸 다시 복호화해서 사진객체로 만드는 데는 속도가 느린 이슈가 있더라고요.
그래서 디바이스의 앱이 설치된 경로를 가져다가 해당 디렉터리에 사진을 만들어두고 읽어오는 형식으로 변경을 하게 되었습니다. 성능 이슈는 해소가 되었습니다. 하지만 한 가지 크리티컬 한 이슈가 있더라고요. 상대 경로가 앱을 새로 빌드하게 되면 바뀐다는 문제가 있었습니다.
그래서 미친 듯이 구글링을 해서 얻은 결론은 "IOS는 빌드 시 경로가 바뀌는 게 정상적인 동작이다"라는 목적지에 도착하게 되었습니다..
그래서 구조적으로 해결하고자 아래와 같이 진행했습니다.
- 사진 저장 시 디바이스의 상대경로를 저장한다.
- 사진 저장 시 사진의 바이너리코드도 같이 저장한다.
- 기본적으로 목록 조회 시 디바이스의 상대경로에 저장된 사진을 우선으로 하며 없을 경우 바이너리 사진을 상대경로에 마이그한다.
정리하자면...!! 🧹🧹🧹🧹🧹🧹🧹🧹
"디바이스의 상대경로를 저장하며 추가적으로 바이너리로 만든 내용도 같이 저장을 하고, 사진을 뿌려주는데 속도를 빠르게 하기 위해 상대경로에 저장된 사진을 우선 적으로 읽고 상대 경로가 바뀌게 되어 읽지 못하는 경우 같이 저장한 바이너리를 상대경로에 다시 생성하게 만든다."
// 이미지 파일을 지정된 경로에 복사
final appDir = await getApplicationDocumentsDirectory();
// 이미지 파일명을 UUID로 생성
final uniqueFileName = '${Uuid().v4()}.${pickedFile.path.split('.').last}';
// 저장할 상대경로를 셋팅
final imagePath = appDir.path + '/' + uniqueFileName;
// 이미지 파일을 실제로 저장
final File newImage = File(imagePath);
// 추가로 바이너리 형식으로 저장
await newImage.writeAsBytes(await pickedFile.readAsBytes());
var file = base64Encode(File(pickedFile.path).readAsBytesSync());
저장된 이미지를 가져오는 부분에서는 아래와 같이 업데이트로 인해 상대경로가 변경되는 경우를 대응했습니다.
Future<Widget> _getImage(PictureEntity entity) async {
if (await File(entity.path).exists()) {
return Image.file(File(entity.path),fit: BoxFit.cover,);
} else {
List<int> bytes = base64Decode(entity.file);
MemoryImage image = MemoryImage(Uint8List.fromList(bytes));
// 이미지 파일을 지정된 경로에 복사
final appDir = await getApplicationDocumentsDirectory();
final uniqueFileName = '${Uuid().v4()}.jpg';
final imagePath = '${appDir.path}/$uniqueFileName';
final File imageFile = File(imagePath);
await imageFile.writeAsBytes(Uint8List.fromList(bytes));
var updated = entity.clone(
id: entity.id,
title: entity.title,
file: entity.file,
path: imagePath,
tag: entity.tag,
bookmark: entity.bookmark
);
await SqlPictureCrudRepository.update(updated);
return Image(image: image,fit: BoxFit.cover,);
}
}
6. 사용한 pubspec.yaml 내용 📜
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
sqflite: ^2.3.0
path: ^1.8.3
uuid: ^4.0.0
url_launcher: ^6.1.5
clipboard: ^0.1.3
http: ^0.13.3
html: ^0.15.0
share_plus: ^6.0.1
gradient_widgets: ^0.6.0
image_picker: ^1.0.4
permission_handler: ^11.0.0
path_provider: ^2.1.1
flutter_native_splash: ^2.3.2
flutter_native_splash:
color: "#000000" # 배경 색상
image: "assets/xxx.png" # 스플래시 이미지 파일 경로
image_dark: "assets/xxx.png" # 다크 모드용 스플래시 이미지 파일 경로 (선택 사항)
fill: true # 이미지를 화면에 맞게 채울지 여부
android: true # Android 스플래시 구성
ios: true # iOS 스플래시 구성
fullscreen: true
flutter_icons:
image_path: "assets/xxx.png"
android: true
ios: true
image_path_android: "assets/xxx.png"
image_path_ios: "assets/xxx.png"
adaptive_icon_background: "#000000"
adaptive_icon_foreground: "assets/xxx.png"
Locker를 사용하며 등록한 라이브러리 전체입니다. 라이브러리 dependency 설정은 "flutter pub add <라이브러리명>"을 활용해 추가할 수 있습니다.
앱 아이콘 및 스플래쉬를 적용하기 위해서는 flutter root directory에서 아래의 커맨드를 실행해야 ios, android 플랫폼 별로 자동으로 설정이 됩니다. 주의 할 점은 앱 아이콘의 경우 백그라운드를 투명으로 생성하시면 안됩니다.!!
IOS의 앱 아이콘 이미지의 경우 알파속성(불투명)이 되어있으면 xcode에서 배포 파일 validation 에서 실패를 하더라구요.
라이브러리 추가
> flutter pub add <라이브러리명>
앱 아이콘 적용
> flutter pub run flutter_launcher_icons:main
앱 스플래쉬 적용
> flutter pub run flutter_native_splash:create
7. 마무리... 그리고 목표 🎬
퇴근 후 약간의 시간을 투자하여 공부 삼아 프로젝트 생성부터 생각한 기능으로 배포까지 빠르게 한 사이클을 돌아보니 많은 걸 느껴졌습니다. Front의 어려움과 UI/UX 적 요소들이 쉽지가 않더라고요. 해당 업을 하시는 분들 존경합니다. 🙇🏻♀️ 🙇🏻
더 하고 싶은 부분은 UI/UX으로의 개선과 현재는 사용자 디바이스 기기에 저장하며 개인만 콘텐츠를 소장하는 기능의 앱인데요. 외부 서버를 두어 사용하는 구조로 변경을 하고 사용자가 보관한 컨텐츠를 public 한 공간으로 공유를 하며 커뮤니티를 할 수 있는 공간을 만들어 보는 게 목표입니다. 🚀
개발을 진행하면서 필요했던 사이트의 링크들입니다.!
안드로이드 그래픽 이미지 : https://www.norio.be/graphic-generator/
앱 아이콘 생성 : https://www.appicon.co
이미지 사이즈 변경 : https://www.iloveimg.com/ko/resize-image/resize-jpg#resize-options,pixels
이미지 백그라운드 제거 : https://www.remove.bg/ko/upload
<Locker 앱 다운로드 원하시면 이미지들을 클릭해 주세요. 🙏>
![]() |
![]() |
< 완성된 앱 영상 입니다. 📽️📽️📽️ >