AppSearch เป็นโซลูชันการค้นหาประสิทธิภาพสูงในอุปกรณ์สำหรับจัดการในพื้นที่ ที่จัดเก็บไว้และเป็นข้อมูลที่มีโครงสร้าง มี API สำหรับจัดทำดัชนีข้อมูลและดึงข้อมูล โดยใช้การค้นหาข้อความแบบเต็ม แอปพลิเคชันสามารถใช้ AppSearch เพื่อเสนอโฆษณาในแอปที่กำหนดเอง ความสามารถในการค้นหา ทำให้ผู้ใช้ค้นหาเนื้อหาได้แม้ในขณะออฟไลน์
AppSearch มีฟีเจอร์ดังต่อไปนี้
- การใช้พื้นที่เก็บข้อมูลที่รวดเร็ว เน้นอุปกรณ์เคลื่อนที่เป็นหลัก และมีการใช้งาน I/O ต่ำ
- การจัดทำดัชนีและการค้นหาชุดข้อมูลขนาดใหญ่ที่มีประสิทธิภาพสูง
- การรองรับหลายภาษา เช่น อังกฤษและสเปน
- การจัดอันดับความเกี่ยวข้องและการให้คะแนนการใช้งาน
เนื่องจากการใช้งาน I/O ต่ำกว่า AppSearch จึงใช้เวลาในการตอบสนองน้อยลงในการจัดทำดัชนีและการค้นหา ชุดข้อมูลขนาดใหญ่เมื่อเทียบกับ SQLite AppSearch ช่วยให้การค้นหาแบบข้ามประเภทง่ายขึ้น โดยการรองรับการค้นหาเดี่ยวในขณะที่ SQLite ผสานผลลัพธ์จากหลายตาราง
เรามาดูตัวอย่างเพลงสำหรับดูฟีเจอร์ของ AppSearch กัน ซึ่งเป็นแอปพลิเคชันที่จัดการเพลงโปรดของผู้ใช้ และช่วยให้ผู้ใช้ค้นหา ให้กับแอปเหล่านั้น ผู้ใช้เพลิดเพลินกับเพลงจากทั่วโลกโดยใช้ชื่อเพลงต่างๆ ซึ่ง AppSearch รองรับการจัดทำดัชนีและการค้นหาโดยตรงอยู่แล้ว เมื่อ ผู้ใช้ค้นหาเพลงจากชื่อเพลงหรือชื่อศิลปิน แอปพลิเคชันผ่านเพียง ส่งคำขอให้ AppSearch เรียกดูเพลงที่ตรงกันได้อย่างรวดเร็วและมีประสิทธิภาพ แอปพลิเคชันจะแสดงผลลัพธ์ ซึ่งทำให้ผู้ใช้เริ่มเล่นได้อย่างรวดเร็ว เพลงโปรดของพวกเขา
ตั้งค่า
หากต้องการใช้ AppSearch ในแอปพลิเคชัน ให้เพิ่มทรัพยากร Dependency ต่อไปนี้ลงใน
ไฟล์ build.gradle
ของแอปพลิเคชัน:
ดึงดูด
dependencies { def appsearch_version = "1.1.0-alpha05" implementation "androidx.appsearch:appsearch:$appsearch_version" // Use kapt instead of annotationProcessor if writing Kotlin classes annotationProcessor "androidx.appsearch:appsearch-compiler:$appsearch_version" implementation "androidx.appsearch:appsearch-local-storage:$appsearch_version" // PlatformStorage is compatible with Android 12+ devices, and offers additional features // to LocalStorage. implementation "androidx.appsearch:appsearch-platform-storage:$appsearch_version" }
Kotlin
dependencies { val appsearch_version = "1.1.0-alpha05" implementation("androidx.appsearch:appsearch:$appsearch_version") // Use annotationProcessor instead of kapt if writing Java classes kapt("androidx.appsearch:appsearch-compiler:$appsearch_version") implementation("androidx.appsearch:appsearch-local-storage:$appsearch_version") // PlatformStorage is compatible with Android 12+ devices, and offers additional features // to LocalStorage. implementation("androidx.appsearch:appsearch-platform-storage:$appsearch_version") }
แนวคิดของ AppSearch
แผนภาพต่อไปนี้แสดงแนวคิดของ AppSearch และการโต้ตอบของแนวคิด
ฐานข้อมูลและเซสชัน
ฐานข้อมูล AppSearch คือคอลเล็กชันเอกสารที่สอดคล้องกับฐานข้อมูล สคีมา แอปพลิเคชันไคลเอ็นต์สร้างฐานข้อมูลด้วยการมอบแอปพลิเคชัน บริบทและชื่อฐานข้อมูล เฉพาะแอปพลิเคชันเท่านั้นที่สามารถเปิดฐานข้อมูลได้ ที่สร้างมันขึ้นมา เมื่อมีการเปิดฐานข้อมูล เซสชันจะถูกส่งคืนเพื่อโต้ตอบ กับฐานข้อมูล เซสชันคือจุดแรกเข้าสำหรับการเรียกใช้ AppSearch API และจะยังคงเปิดอยู่จนกว่าแอปพลิเคชันไคลเอ็นต์จะปิด
สคีมาและประเภทสคีมา
สคีมาแสดงโครงสร้างองค์กรของข้อมูลภายใน AppSearch ฐานข้อมูล
สคีมาประกอบด้วยประเภทสคีมาที่แสดงประเภทข้อมูลที่ไม่ซ้ำกัน ประเภทสคีมาประกอบด้วยพร็อพเพอร์ตี้ที่มีชื่อ ประเภทข้อมูล และ Cardinality เมื่อเพิ่มประเภทสคีมาในสคีมาฐานข้อมูลแล้ว เอกสารของ สามารถสร้างและเพิ่มสคีมาประเภทนั้นลงในฐานข้อมูลได้
เอกสาร
ใน AppSearch หน่วยข้อมูลจะแสดงเป็นเอกสาร เอกสารแต่ละรายการใน ฐานข้อมูล AppSearch จะได้รับการระบุโดยไม่ซ้ำกันด้วยเนมสเปซและรหัส เนมสเปซ ใช้เพื่อแยกข้อมูลจากแหล่งที่มาต่างๆ เมื่อต้องใช้แหล่งข้อมูลเพียงแหล่งเดียว ที่มีการค้นหา เช่น บัญชีผู้ใช้
เอกสารจะมีการประทับเวลาการสร้าง, Time to Live (TTL) และคะแนนที่ สามารถใช้ในการจัดอันดับระหว่างการดึงข้อมูลได้ ระบบกำหนดสคีมาให้กับเอกสารแล้ว ที่อธิบายพร็อพเพอร์ตี้ข้อมูลเพิ่มเติมที่เอกสารต้องมี
คลาสเอกสารคือนามธรรมของเอกสาร มีช่องที่มีคำอธิบายประกอบ ที่แสดงถึงเนื้อหาของเอกสาร โดยค่าเริ่มต้น ชื่อเอกสาร class จะตั้งชื่อของประเภทสคีมา
ค้นหา
เอกสารจะได้รับการจัดทำดัชนีและค้นหาได้โดยการใส่คำค้นหา เอกสารคือ ตรงกับข้อความค้นหาและรวมอยู่ในผลการค้นหาด้วยหากมีคำที่อยู่ในข้อความค้นหา หรือตรงกับข้อกำหนดการค้นหาอื่น ผลลัพธ์มีการเรียงลำดับตาม คะแนนและกลยุทธ์การจัดอันดับ ผลการค้นหาจะแสดงด้วยหน้าที่คุณสามารถ ตามลำดับ
AppSearch มีการปรับแต่ง สำหรับการค้นหา เช่น ตัวกรอง การกำหนดค่าขนาดหน้า และข้อมูลโค้ด
พื้นที่เก็บข้อมูลของแพลตฟอร์มเทียบกับพื้นที่เก็บข้อมูลในเครื่อง
AppSearch มีโซลูชันพื้นที่เก็บข้อมูล 2 แบบ ได้แก่ LocalStorage และ PlatformStorage เมื่อใช้ LocalStorage แอปพลิเคชันของคุณจะจัดการดัชนีเฉพาะแอปที่ใช้งานอยู่ ไดเรกทอรีข้อมูลแอปพลิเคชันของคุณ เมื่อใช้ PlatformStorage แอปพลิเคชัน ทำให้เกิดดัชนีกลางทั้งระบบ การเข้าถึงข้อมูลภายในดัชนีกลาง จำกัดเฉพาะข้อมูลที่แอปพลิเคชันของคุณได้ให้ไว้และข้อมูล ที่แอปพลิเคชันอื่นแชร์กับคุณอย่างชัดเจน ทั้ง LocalStorage และ PlatformStorage ใช้ API เดียวกันและสลับกันได้ตาม เวอร์ชัน:
Kotlin
if (BuildCompat.isAtLeastS()) { appSearchSessionFuture.setFuture( PlatformStorage.createSearchSession( PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME) .build() ) ) } else { appSearchSessionFuture.setFuture( LocalStorage.createSearchSession( LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME) .build() ) ) }
Java
if (BuildCompat.isAtLeastS()) { mAppSearchSessionFuture.setFuture(PlatformStorage.createSearchSession( new PlatformStorage.SearchContext.Builder(mContext, DATABASE_NAME) .build())); } else { mAppSearchSessionFuture.setFuture(LocalStorage.createSearchSession( new LocalStorage.SearchContext.Builder(mContext, DATABASE_NAME) .build())); }
เมื่อใช้ PlatformStorage แอปพลิเคชันของคุณจะแชร์ข้อมูลกับผู้อื่นได้อย่างปลอดภัย แอปพลิเคชันเพื่อให้ค้นหาข้อมูลในแอปได้ด้วย อ่านอย่างเดียว จะมีการแชร์ข้อมูลแอปพลิเคชันผ่านแฮนด์เชคใบรับรองเพื่อให้แน่ใจว่า แอปพลิเคชันอื่นมีสิทธิ์ในการอ่านข้อมูล อ่านเพิ่มเติมเกี่ยวกับ API นี้ ในเอกสารประกอบสำหรับ setSchemaType visibilityForPackage()
นอกจากนี้ ข้อมูลที่มีการจัดทำดัชนีจะแสดงบนแพลตฟอร์ม UI ของระบบได้ด้วย แอปพลิเคชันสามารถเลือกไม่ใช้ข้อมูลบางส่วนหรือทั้งหมดที่แสดงในระบบได้ แพลตฟอร์ม UI อ่านเพิ่มเติมเกี่ยวกับ API นี้ในเอกสารประกอบสำหรับ setSchemaTypeDisplayedBySystem()
ฟีเจอร์ | LocalStorage (compatible with Android 4.0+) |
PlatformStorage (compatible with Android 12+) |
---|---|---|
Efficient full-text search |
||
Multi-language support |
||
Reduced binary size |
||
Application-to-application data sharing |
||
Capability to display data on System UI surfaces |
||
Unlimited document size and count can be indexed |
||
Faster operations without additional binder latency |
แต่ยังมีข้อดีอื่นๆ ที่ต้องพิจารณาเมื่อเลือกระหว่าง LocalStorage และ PlatformStorage เนื่องจาก PlatformStorage รวม API ของ Jetpack ไว้มากกว่า บริการระบบของ AppSearch ผลกระทบของขนาด APK นั้นน้อยที่สุด เมื่อเทียบกับการใช้ LocalStorage แต่หมายความว่าการดำเนินการของ AppSearch ยังต้องดำเนินการเพิ่ม เวลาในการตอบสนองของ Binder เมื่อเรียกใช้บริการของระบบ AppSearch PlatformStorage AppSearch จำกัดจำนวนเอกสารและขนาดเอกสารของแอปพลิเคชัน สามารถจัดทำดัชนีเพื่อให้มั่นใจว่าดัชนีกลางที่มีประสิทธิภาพ
เริ่มต้นใช้งาน AppSearch
ตัวอย่างในส่วนนี้จะแสดงวิธีใช้ AppSearch API เพื่อผสานรวม ด้วยแอปพลิเคชันจดโน้ตสมมติ
เขียนชั้นเรียนในเอกสาร
ขั้นตอนแรกในการผสานรวมกับ AppSearch คือการเขียนคลาสเอกสาร
อธิบายข้อมูลที่จะแทรกลงในฐานข้อมูล ทำเครื่องหมายชั้นเรียนเป็นชั้นเรียนเอกสาร
โดยใช้ @Document
คำอธิบายประกอบ คุณสามารถใช้อินสแตนซ์ของคลาสเอกสารเพื่อใส่เอกสาร และ
เรียกเอกสารจากฐานข้อมูล
โค้ดต่อไปนี้จะระบุคลาสเอกสาร Note ที่มี
มีคำอธิบายประกอบ @Document.StringProperty
แล้ว
สำหรับจัดทำดัชนีข้อความของออบเจ็กต์หมายเหตุ
Kotlin
@Document public data class Note( // Required field for a document class. All documents MUST have a namespace. @Document.Namespace val namespace: String, // Required field for a document class. All documents MUST have an Id. @Document.Id val id: String, // Optional field for a document class, used to set the score of the // document. If this is not included in a document class, the score is set // to a default of 0. @Document.Score val score: Int, // Optional field for a document class, used to index a note's text for this // document class. @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES) val text: String )
Java
@Document public class Note { // Required field for a document class. All documents MUST have a namespace. @Document.Namespace private final String namespace; // Required field for a document class. All documents MUST have an Id. @Document.Id private final String id; // Optional field for a document class, used to set the score of the // document. If this is not included in a document class, the score is set // to a default of 0. @Document.Score private final int score; // Optional field for a document class, used to index a note's text for this // document class. @Document.StringProperty(indexingType = StringPropertyConfig.INDEXING_TYPE_PREFIXES) private final String text; Note(@NonNull String id, @NonNull String namespace, int score, @NonNull String text) { this.id = Objects.requireNonNull(id); this.namespace = Objects.requireNonNull(namespace); this.score = score; this.text = Objects.requireNonNull(text); } @NonNull public String getNamespace() { return namespace; } @NonNull public String getId() { return id; } public int getScore() { return score; } @NonNull public String getText() { return text; } }
เปิดฐานข้อมูล
คุณต้องสร้างฐานข้อมูลก่อนที่จะทำงานกับเอกสาร รหัสต่อไปนี้
สร้างฐานข้อมูลใหม่ชื่อ notes_app
และรับ ListenableFuture
ในราคา AppSearchSession
ซึ่งแสดงถึงการเชื่อมต่อกับฐานข้อมูล และให้ API สำหรับ
การดำเนินงานฐานข้อมูล
Kotlin
val context: Context = getApplicationContext() val sessionFuture = LocalStorage.createSearchSession( LocalStorage.SearchContext.Builder(context, /*databaseName=*/"notes_app") .build() )
Java
Context context = getApplicationContext(); ListenableFuture<AppSearchSession> sessionFuture = LocalStorage.createSearchSession( new LocalStorage.SearchContext.Builder(context, /*databaseName=*/ "notes_app") .build() );
ตั้งค่าสคีมา
คุณต้องตั้งค่าสคีมาก่อนจึงจะใส่เอกสารและเรียกข้อมูลได้ จากฐานข้อมูล สคีมาฐานข้อมูลประกอบด้วยประเภทต่างๆ ของข้อมูลที่มีโครงสร้าง ซึ่งเรียกว่า "ประเภทสคีมา" โค้ดต่อไปนี้ตั้งค่า โดยระบุคลาสเอกสารเป็นประเภทสคีมา
Kotlin
val setSchemaRequest = SetSchemaRequest.Builder().addDocumentClasses(Note::class.java) .build() val setSchemaFuture = Futures.transformAsync( sessionFuture, { session -> session?.setSchema(setSchemaRequest) }, mExecutor )
Java
SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder().addDocumentClasses(Note.class) .build(); ListenableFuture<SetSchemaResponse> setSchemaFuture = Futures.transformAsync(sessionFuture, session -> session.setSchema(setSchemaRequest), mExecutor);
วางเอกสารในฐานข้อมูล
เมื่อเพิ่มประเภทสคีมาแล้ว คุณสามารถเพิ่มเอกสารประเภทนั้นลงในฐานข้อมูลได้
โค้ดต่อไปนี้จะสร้างเอกสารประเภทสคีมา Note
โดยใช้ Note
เครื่องมือสร้างคลาสของเอกสาร โดยจะตั้งค่าเนมสเปซ user1
ของเอกสารเพื่อแสดง
ผู้ใช้ที่กำหนดเองของตัวอย่างนี้ จากนั้นเอกสารจะถูกแทรกในฐานข้อมูล
และ Listener จะแนบไปด้วยเพื่อประมวลผลผลลัพธ์ของคำสั่ง Put
Kotlin
val note = Note( namespace="user1", id="noteId", score=10, text="Buy fresh fruit" ) val putRequest = PutDocumentsRequest.Builder().addDocuments(note).build() val putFuture = Futures.transformAsync( sessionFuture, { session -> session?.put(putRequest) }, mExecutor ) Futures.addCallback( putFuture, object : FutureCallback<AppSearchBatchResult<String, Void>?> { override fun onSuccess(result: AppSearchBatchResult<String, Void>?) { // Gets map of successful results from Id to Void val successfulResults = result?.successes // Gets map of failed results from Id to AppSearchResult val failedResults = result?.failures } override fun onFailure(t: Throwable) { Log.e(TAG, "Failed to put documents.", t) } }, mExecutor )
Java
Note note = new Note(/*namespace=*/"user1", /*id=*/ "noteId", /*score=*/ 10, /*text=*/ "Buy fresh fruit!"); PutDocumentsRequest putRequest = new PutDocumentsRequest.Builder().addDocuments(note) .build(); ListenableFuture<AppSearchBatchResult<String, Void>> putFuture = Futures.transformAsync(sessionFuture, session -> session.put(putRequest), mExecutor); Futures.addCallback(putFuture, new FutureCallback<AppSearchBatchResult<String, Void>>() { @Override public void onSuccess(@Nullable AppSearchBatchResult<String, Void> result) { // Gets map of successful results from Id to Void Map<String, Void> successfulResults = result.getSuccesses(); // Gets map of failed results from Id to AppSearchResult Map<String, AppSearchResult<Void>> failedResults = result.getFailures(); } @Override public void onFailure(@NonNull Throwable t) { Log.e(TAG, "Failed to put documents.", t); } }, mExecutor);
ค้นหา
คุณสามารถค้นหาเอกสารที่มีการจัดทำดัชนีโดยใช้การดำเนินการค้นหาที่ครอบคลุมใน
ส่วนนี้ โค้ดต่อไปนี้ดำเนินการค้นหาคำว่า "ผลไม้" ในช่วง
ฐานข้อมูลสำหรับเอกสารที่อยู่ในเนมสเปซ user1
Kotlin
val searchSpec = SearchSpec.Builder() .addFilterNamespaces("user1") .build(); val searchFuture = Futures.transform( sessionFuture, { session -> session?.search("fruit", searchSpec) }, mExecutor ) Futures.addCallback( searchFuture, object : FutureCallback<SearchResults> { override fun onSuccess(searchResults: SearchResults?) { iterateSearchResults(searchResults) } override fun onFailure(t: Throwable?) { Log.e("TAG", "Failed to search notes in AppSearch.", t) } }, mExecutor )
Java
SearchSpec searchSpec = new SearchSpec.Builder() .addFilterNamespaces("user1") .build(); ListenableFuture<SearchResults> searchFuture = Futures.transform(sessionFuture, session -> session.search("fruit", searchSpec), mExecutor); Futures.addCallback(searchFuture, new FutureCallback<SearchResults>() { @Override public void onSuccess(@Nullable SearchResults searchResults) { iterateSearchResults(searchResults); } @Override public void onFailure(@NonNull Throwable t) { Log.e(TAG, "Failed to search notes in AppSearch.", t); } }, mExecutor);
ทำซ้ำผ่านผลการค้นหา
การค้นหาจะแสดง SearchResults
ซึ่งให้สิทธิ์เข้าถึงหน้าของออบเจ็กต์ SearchResult
แต่ละSearchResult
ตรงกับ GenericDocument
ซึ่งเป็นรูปแบบทั่วไปของ
ที่มีการแปลงเอกสารทั้งหมด โค้ดต่อไปนี้จะได้รับ
หน้าผลการค้นหา และแปลงผลลัพธ์กลับไปเป็นเอกสาร Note
Kotlin
Futures.transform( searchResults?.nextPage, { page: List<SearchResult>? -> // Gets GenericDocument from SearchResult. val genericDocument: GenericDocument = page!![0].genericDocument val schemaType = genericDocument.schemaType val note: Note? = try { if (schemaType == "Note") { // Converts GenericDocument object to Note object. genericDocument.toDocumentClass(Note::class.java) } else null } catch (e: AppSearchException) { Log.e( TAG, "Failed to convert GenericDocument to Note", e ) null } note }, mExecutor )
Java
Futures.transform(searchResults.getNextPage(), page -> { // Gets GenericDocument from SearchResult. GenericDocument genericDocument = page.get(0).getGenericDocument(); String schemaType = genericDocument.getSchemaType(); Note note = null; if (schemaType.equals("Note")) { try { // Converts GenericDocument object to Note object. note = genericDocument.toDocumentClass(Note.class); } catch (AppSearchException e) { Log.e(TAG, "Failed to convert GenericDocument to Note", e); } } return note; }, mExecutor);
นำเอกสารออก
เมื่อผู้ใช้ลบโน้ต แอปพลิเคชันจะลบ Note
ที่เกี่ยวข้อง
จากฐานข้อมูล วิธีนี้ช่วยให้มั่นใจว่าบันทึกจะไม่ปรากฏใน
การค้นหา โค้ดต่อไปนี้ส่งคำขออย่างชัดแจ้งให้นำ Note
ออก
จากฐานข้อมูลตามรหัส
Kotlin
val removeRequest = RemoveByDocumentIdRequest.Builder("user1") .addIds("noteId") .build() val removeFuture = Futures.transformAsync( sessionFuture, { session -> session?.remove(removeRequest) }, mExecutor )
Java
RemoveByDocumentIdRequest removeRequest = new RemoveByDocumentIdRequest.Builder("user1") .addIds("noteId") .build(); ListenableFuture<AppSearchBatchResult<String, Void>> removeFuture = Futures.transformAsync(sessionFuture, session -> session.remove(removeRequest), mExecutor);
คงอยู่ในดิสก์
ควรอัปเดตฐานข้อมูลไปยังดิสก์เป็นระยะๆ โดยการเรียกใช้
requestFlush()
การเรียกโค้ดต่อไปนี้ requestFlush()
พร้อมกับ Listener เพื่อระบุว่าเป็นการโทร
ประสบความสำเร็จ
Kotlin
val requestFlushFuture = Futures.transformAsync( sessionFuture, { session -> session?.requestFlush() }, mExecutor ) Futures.addCallback(requestFlushFuture, object : FutureCallback<Void?> { override fun onSuccess(result: Void?) { // Success! Database updates have been persisted to disk. } override fun onFailure(t: Throwable) { Log.e(TAG, "Failed to flush database updates.", t) } }, mExecutor)
Java
ListenableFuture<Void> requestFlushFuture = Futures.transformAsync(sessionFuture, session -> session.requestFlush(), mExecutor); Futures.addCallback(requestFlushFuture, new FutureCallback<Void>() { @Override public void onSuccess(@Nullable Void result) { // Success! Database updates have been persisted to disk. } @Override public void onFailure(@NonNull Throwable t) { Log.e(TAG, "Failed to flush database updates.", t); } }, mExecutor);
ปิดเซสชัน
AppSearchSession
ควรปิดเมื่อแอปพลิเคชันจะไม่เรียกใช้ฐานข้อมูลใดๆ อีก
การดำเนินงาน โค้ดต่อไปนี้จะปิดเซสชัน AppSearch ที่เปิดอยู่
ก่อนหน้านี้และยังคงอัปเดตดิสก์ทั้งหมด
Kotlin
val closeFuture = Futures.transform<AppSearchSession, Unit>(sessionFuture, { session -> session?.close() Unit }, mExecutor )
Java
ListenableFuture<Void> closeFuture = Futures.transform(sessionFuture, session -> { session.close(); return null; }, mExecutor);
แหล่งข้อมูลเพิ่มเติม
ดูข้อมูลเพิ่มเติมเกี่ยวกับ AppSearch ได้ในแหล่งข้อมูลเพิ่มเติมต่อไปนี้
ตัวอย่าง
- ตัวอย่าง Android AppSearch (Kotlin) แอปจดโน้ตที่ใช้ AppSearch เพื่อจัดทำดัชนีโน้ตของผู้ใช้ และช่วยให้ผู้ใช้ เพื่อค้นหาโน้ตของตนเอง
แสดงความคิดเห็น
แชร์ความคิดเห็นและไอเดียกับเราผ่านแหล่งข้อมูลเหล่านี้
รายงานข้อบกพร่องเพื่อให้เราแก้ไขได้