摘要:源碼閱讀之?dāng)?shù)據(jù)庫連接的配置文件所有配置會(huì)被類讀取,我們可以通過此類來了解各個(gè)配置是如何運(yùn)作的。也就是說的統(tǒng)計(jì)字段是關(guān)于整個(gè)數(shù)據(jù)源的,而一個(gè)則是針對(duì)單個(gè)連接的。
MyBatis 源碼閱讀之?dāng)?shù)據(jù)庫連接
MyBatis 的配置文件所有配置會(huì)被 org.apache.ibatis.builder.xml.XMLConfigBuilder 類讀取,
我們可以通過此類來了解各個(gè)配置是如何運(yùn)作的。
而 MyBatis 的映射文件配置會(huì)被 org.apache.ibatis.builder.xml.XMLMapperBuilder 類讀取。
我們可以通過此類來了解映射文件的配置時(shí)如何被解析的。
本文探討 事務(wù)管理器 和 數(shù)據(jù)源 相關(guān)代碼配置 environment
以下是 mybatis 配置文件中 environments 節(jié)點(diǎn)的一般配置。
environments 節(jié)點(diǎn)的加載也不算復(fù)雜,它只會(huì)加載 id 為 development 屬性值的 environment 節(jié)點(diǎn)。
它的加載代碼在 XMLConfigBuilder 類的 environmentsElement() 方法中,代碼不多,邏輯也簡單,此處不多講。
接下來我們看看 environment 節(jié)點(diǎn)下的子節(jié)點(diǎn)。transactionManager 節(jié)點(diǎn)的 type 值默認(rèn)提供有 JDBC 和 MANAGED ,dataSource 節(jié)點(diǎn)的 type 值默認(rèn)提供有 JNDI 、 POOLED 和 UNPOOLED 。
它們對(duì)應(yīng)的類都可以在 Configuration 類的構(gòu)造器中找到,當(dāng)然下面我們也一個(gè)一個(gè)來分析。
現(xiàn)在我們大概了解了配置,然后來分析這些配置與 MyBatis 類的關(guān)系。
TransactionFactorytransactionManager 節(jié)點(diǎn)對(duì)應(yīng) TransactionFactory 接口,使用了 抽象工廠模式 。MyBatis 給我們提供了兩個(gè)實(shí)現(xiàn)類:ManagedTransactionFactory 和 JdbcTransactionFactory ,它們分別對(duì)應(yīng)者 type 屬性值為 MANAGED 和 JDBC 。
TransactionFactory 有三個(gè)方法,我們需要注意的方法只有 newTransaction() ,它用來創(chuàng)建一個(gè)事務(wù)對(duì)象。
void setProperties(Properties props); Transaction newTransaction(Connection conn); Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit);
其中 JdbcTransactionFactory 創(chuàng)建的事務(wù)對(duì)象是 JdbcTransaction 的實(shí)例,該實(shí)例是對(duì) JDBC 事務(wù)的簡單封裝,實(shí)例中 Connection 和 DataSource 對(duì)象正是事務(wù)所在的 連接 和 數(shù)據(jù)源 。
TransactionIsolationLevel 代表當(dāng)前事務(wù)的隔離等級(jí),它是一個(gè)枚舉類,簡單明了無需多言。而 autoCommit 表示是否開啟了自動(dòng)提交,開啟了,則沒有事務(wù)的提交和回滾等操作的意義了。
ManagedTransactionFactory 創(chuàng)建的事務(wù)對(duì)象是 ManagedTransaction 的實(shí)例,它本身并不控制事務(wù),即 commit 和 rollback 都是不做任何操作,而是交由 JavaEE 容器來控制事務(wù),以方便集成。
DataSourceFactoryDataSourceFactory 是獲取數(shù)據(jù)源的接口,也使用了 抽象工廠模式 ,代碼如下,方法極為簡單:
public interface DataSourceFactory { /** * 可傳入一些屬性配置 */ void setProperties(Properties props); DataSource getDataSource(); }
MyBatis 默認(rèn)支持三種數(shù)據(jù)源,分別是 UNPOOLED 、 POOLED 和 JNDI 。對(duì)應(yīng)三個(gè)工廠類:
UnpooledDataSourceFactory 、 PooledDataSourceFactory 和 JNDIDataSourceFactory 。
其中 JNDIDataSourceFactory 是使用 JNDI 來獲取數(shù)據(jù)源。我們很少使用,并且代碼不是非常復(fù)雜,此處不討論。我們先來看看 UnpooledDataSourceFactory :
public class UnpooledDataSourceFactory implements DataSourceFactory { private static final String DRIVER_PROPERTY_PREFIX = "driver."; private static final int DRIVER_PROPERTY_PREFIX_LENGTH = DRIVER_PROPERTY_PREFIX.length(); protected DataSource dataSource; public UnpooledDataSourceFactory() { this.dataSource = new UnpooledDataSource(); } @Override public void setProperties(Properties properties) { Properties driverProperties = new Properties(); // MetaObject 用于解析實(shí)例對(duì)象的元信息,如字段的信息、方法的信息 MetaObject metaDataSource = SystemMetaObject.forObject(dataSource); // 解析所有配置的鍵值對(duì)key-value,發(fā)現(xiàn)非預(yù)期的屬性立即拋異常,以便及時(shí)發(fā)現(xiàn) for (Object key : properties.keySet()) { String propertyName = (String) key; if (propertyName.startsWith(DRIVER_PROPERTY_PREFIX)) { // 添加驅(qū)動(dòng)的配置屬性 String value = properties.getProperty(propertyName); driverProperties.setProperty(propertyName.substring(DRIVER_PROPERTY_PREFIX_LENGTH), value); } else if (metaDataSource.hasSetter(propertyName)) { // 為數(shù)據(jù)源添加配置屬性 String value = (String) properties.get(propertyName); Object convertedValue = convertValue(metaDataSource, propertyName, value); metaDataSource.setValue(propertyName, convertedValue); } else { throw new DataSourceException("Unknown DataSource property: " + propertyName); } } if (driverProperties.size() > 0) { metaDataSource.setValue("driverProperties", driverProperties); } } @Override public DataSource getDataSource() { return dataSource; } /** * 將 String 類型的值轉(zhuǎn)為目標(biāo)對(duì)象字段的類型的值 */ private Object convertValue(MetaObject metaDataSource, String propertyName, String value) { Object convertedValue = value; Class> targetType = metaDataSource.getSetterType(propertyName); if (targetType == Integer.class || targetType == int.class) { convertedValue = Integer.valueOf(value); } else if (targetType == Long.class || targetType == long.class) { convertedValue = Long.valueOf(value); } else if (targetType == Boolean.class || targetType == boolean.class) { convertedValue = Boolean.valueOf(value); } return convertedValue; } }
雖然代碼看起來復(fù)雜,實(shí)際上非常簡單,在創(chuàng)建工廠實(shí)例時(shí)創(chuàng)建它對(duì)應(yīng)的 UnpooledDataSource 數(shù)據(jù)源。
setProperties() 方法用于給數(shù)據(jù)源添加部分屬性配置,convertValue() 方式時(shí)一個(gè)私有方法,就是處理 當(dāng) DataSource 的屬性為整型或布爾類型時(shí)提供對(duì)字符串類型的轉(zhuǎn)換功能而已。
最后我們看看 PooledDataSourceFactory ,這個(gè)類非常簡單,僅僅是繼承了 UnpooledDataSourceFactory ,然后構(gòu)造方法替換數(shù)據(jù)源為 PooledDataSource 。
public class PooledDataSourceFactory extends UnpooledDataSourceFactory { public PooledDataSourceFactory() { this.dataSource = new PooledDataSource(); } }
雖然它的代碼極少,實(shí)際上都在 PooledDataSource 類中。
DataSource看完了工廠類,我們來看看 MyBatis 提供的兩種數(shù)據(jù)源類: UnpooledDataSource 和 PooledDataSource 。
UnpooledDataSourceUnpooledDataSource 看名字就知道是沒有池化的特征,相對(duì)也簡單點(diǎn),以下代碼省略一些不重要的方法
import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class UnpooledDataSource implements DataSource { private ClassLoader driverClassLoader; private Properties driverProperties; private static MapregisteredDrivers = new ConcurrentHashMap (); private String driver; private String url; private String username; private String password; private Boolean autoCommit; // 事務(wù)隔離級(jí)別 private Integer defaultTransactionIsolationLevel; static { // 遍歷所有可用驅(qū)動(dòng) Enumeration drivers = DriverManager.getDrivers(); while (drivers.hasMoreElements()) { Driver driver = drivers.nextElement(); registeredDrivers.put(driver.getClass().getName(), driver); } } // ...... private Connection doGetConnection(Properties properties) throws SQLException { // 每次獲取連接都會(huì)檢測驅(qū)動(dòng) initializeDriver(); Connection connection = DriverManager.getConnection(url, properties); configureConnection(connection); return connection; } /** * 初始化驅(qū)動(dòng),這是一個(gè) 同步 方法 */ private synchronized void initializeDriver() throws SQLException { // 如果不包含驅(qū)動(dòng),則準(zhǔn)備添加驅(qū)動(dòng) if (!registeredDrivers.containsKey(driver)) { Class> driverType; try { // 加載驅(qū)動(dòng) if (driverClassLoader != null) { driverType = Class.forName(driver, true, driverClassLoader); } else { driverType = Resources.classForName(driver); } Driver driverInstance = (Driver)driverType.newInstance(); // 注冊驅(qū)動(dòng)代理到 DriverManager DriverManager.registerDriver(new DriverProxy(driverInstance)); // 緩存驅(qū)動(dòng) registeredDrivers.put(driver, driverInstance); } catch (Exception e) { throw new SQLException("Error setting driver on UnpooledDataSource. Cause: " + e); } } } private void configureConnection(Connection conn) throws SQLException { // 設(shè)置是否自動(dòng)提交事務(wù) if (autoCommit != null && autoCommit != conn.getAutoCommit()) { conn.setAutoCommit(autoCommit); } // 設(shè)置 事務(wù)隔離級(jí)別 if (defaultTransactionIsolationLevel != null) { conn.setTransactionIsolation(defaultTransactionIsolationLevel); } } private static class DriverProxy implements Driver { private Driver driver; DriverProxy(Driver d) { this.driver = d; } /** * Driver 僅在 JDK7 中定義了本方法,用于返回本驅(qū)動(dòng)的所有日志記錄器的父記錄器 * 個(gè)人也不是十分明確它的用法,畢竟很少會(huì)關(guān)注驅(qū)動(dòng)的日志 */ public Logger getParentLogger() { return Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); } // 其他方法均為調(diào)用 driver 對(duì)應(yīng)的方法,此處省略 } }
這里 DriverProxy 僅被注冊到 DriverManager 中,這是一個(gè)代理操作,但源碼上并沒有什么特別的處理代碼,我也不懂官方為什么在這里加代理,有誰明白的可以留言相互討論。這里的其他方法也不是非常復(fù)雜,我都已經(jīng)標(biāo)有注釋,應(yīng)該都可以看懂,不再細(xì)說。
以上便是 UnpooledDataSource 的初始化驅(qū)動(dòng)和獲取連接關(guān)鍵代碼。
PooledDataSource接下來我們來看最后一個(gè)類 PooledDataSource ,它也是直接實(shí)現(xiàn) DataSource ,不過因?yàn)閾碛谐鼗奶匦裕拇a復(fù)雜不少,當(dāng)然效率比 UnpooledDataSource 會(huì)高出不少。
PooledDataSource 通過兩個(gè)輔助類 PoolState 和 PooledConnection 來完成池化功能。
PoolState 是記錄連接池運(yùn)行時(shí)的狀態(tài),定義了兩個(gè) PooledConnection 集合用于記錄空閑連接和活躍連接。
PooledConnection 內(nèi)部定義了兩個(gè) Connection 分別表示一個(gè)真實(shí)連接和代理連接,還有一些其他字段用于記錄一個(gè)連接的運(yùn)行時(shí)狀態(tài)。
先來詳細(xì)了解一下 PooledConnection
/** * 此處使用默認(rèn)的訪問權(quán)限 * 實(shí)現(xiàn)了 InvocationHandler */ class PooledConnection implements InvocationHandler { private static final String CLOSE = "close"; private static final Class>[] IFACES = new Class>[] { Connection.class }; /** hashCode() 方法返回 */ private final int hashCode; private final Connection realConnection; private final Connection proxyConnection; // 省略 checkoutTimestamp、createdTimestamp、lastUsedTimestamp private boolean valid; /* * Constructor for SimplePooledConnection that uses the Connection and PooledDataSource passed in * * @param connection - the connection that is to be presented as a pooled connection * @param dataSource - the dataSource that the connection is from */ public PooledConnection(Connection connection, PooledDataSource dataSource) { this.hashCode = connection.hashCode(); this.realConnection = connection; this.dataSource = dataSource; this.createdTimestamp = System.currentTimeMillis(); this.lastUsedTimestamp = System.currentTimeMillis(); this.valid = true; this.proxyConnection = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), IFACES, this); } /* * 設(shè)置連接狀態(tài)為不正常,不可使用 */ public void invalidate() { valid = false; } /* * 查看連接是否可用 * * @return 如果可用則返回 true */ public boolean isValid() { return valid && realConnection != null && dataSource.pingConnection(this); } /** * 自動(dòng)上一次使用后經(jīng)過的時(shí)間 */ public long getTimeElapsedSinceLastUse() { return System.currentTimeMillis() - lastUsedTimestamp; } /** * 存活時(shí)間 */ public long getAge() { return System.currentTimeMillis() - createdTimestamp; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String methodName = method.getName(); if (CLOSE.hashCode() == methodName.hashCode() && CLOSE.equals(methodName)) { // 對(duì)于 close() 方法,將連接放回池中 dataSource.pushConnection(this); return null; } else { try { if (!Object.class.equals(method.getDeclaringClass())) { checkConnection(); } return method.invoke(realConnection, args); } catch (Throwable t) { throw ExceptionUtil.unwrapThrowable(t); } } } private void checkConnection() throws SQLException { if (!valid) { throw new SQLException("Error accessing PooledConnection. Connection is invalid."); } } }
本類實(shí)現(xiàn)了 InvocationHandler 接口,這個(gè)接口是用于 JDK 動(dòng)態(tài)代理的,在這個(gè)類的構(gòu)造器中 proxyConnection 就是創(chuàng)建了此代理對(duì)象。
來看看 invoke() 方法,它攔截了 close() 方法,不再關(guān)閉連接,而是將其繼續(xù)放入池中,然后其他已實(shí)現(xiàn)的方法則是每次調(diào)用都需要檢測連接是否合法。
而 PoolState 類,這個(gè)類實(shí)際上沒什么可說的,都是一些統(tǒng)計(jì)字段,沒有復(fù)雜邏輯,不討論; 需要注意該類是針對(duì)一個(gè) PooledDataSource 對(duì)象統(tǒng)計(jì)的 。
也就是說 PoolState 的統(tǒng)計(jì)字段是關(guān)于整個(gè)數(shù)據(jù)源的,而一個(gè) PooledConnection 則是針對(duì)單個(gè)連接的。
最后我們回過頭來看 PooledDataSource 類,數(shù)據(jù)源的操作就只有兩個(gè),獲取連接,釋放連接,先來看看獲取連接
public class PooledDataSource implements DataSource { private final UnpooledDataSource dataSource; @Override public Connection getConnection() throws SQLException { return popConnection(dataSource.getUsername(), dataSource.getPassword()).getProxyConnection(); } @Override public Connection getConnection(String username, String password) throws SQLException { return popConnection(username, password).getProxyConnection(); } /** * 獲取一個(gè)連接 */ private PooledConnection popConnection(String username, String password) throws SQLException { boolean countedWait = false; PooledConnection conn = null; long t = System.currentTimeMillis(); int localBadConnectionCount = 0; // conn == null 也可能是沒有獲得連接,被通知后再次走流程 while (conn == null) { synchronized (state) { // 是否存在空閑連接 if (!state.idleConnections.isEmpty()) { // 池里存在空閑連接 conn = state.idleConnections.remove(0); } else { // 池里不存在空閑連接 if (state.activeConnections.size() < poolMaximumActiveConnections) { // 池里的激活連接數(shù)小于最大數(shù),創(chuàng)建一個(gè)新的 conn = new PooledConnection(dataSource.getConnection(), this); } else { // 最壞的情況,無法獲取連接 // 檢測最早使用的連接是否超時(shí) PooledConnection oldestActiveConnection = state.activeConnections.get(0); long longestCheckoutTime = oldestActiveConnection.getCheckoutTime(); if (longestCheckoutTime > poolMaximumCheckoutTime) { // 使用超時(shí)連接,對(duì)超時(shí)連接的操作進(jìn)行回滾 state.claimedOverdueConnectionCount++; state.accumulatedCheckoutTimeOfOverdueConnections += longestCheckoutTime; state.accumulatedCheckoutTime += longestCheckoutTime; state.activeConnections.remove(oldestActiveConnection); if (!oldestActiveConnection.getRealConnection().getAutoCommit()) { try { oldestActiveConnection.getRealConnection().rollback(); } catch (SQLException e) { /* * Just log a message for debug and continue to execute the following statement * like nothing happened. Wrap the bad connection with a new PooledConnection, * this will help to not interrupt current executing thread and give current * thread a chance to join the next competition for another valid/good database * connection. At the end of this loop, bad {@link @conn} will be set as null. */ log.debug("Bad connection. Could not roll back"); } } conn = new PooledConnection(oldestActiveConnection.getRealConnection(), this); conn.setCreatedTimestamp(oldestActiveConnection.getCreatedTimestamp()); conn.setLastUsedTimestamp(oldestActiveConnection.getLastUsedTimestamp()); oldestActiveConnection.invalidate(); } else { // 等待可用連接 try { if (!countedWait) { state.hadToWaitCount++; countedWait = true; } long wt = System.currentTimeMillis(); state.wait(poolTimeToWait); state.accumulatedWaitTime += System.currentTimeMillis() - wt; } catch (InterruptedException e) { break; } } } } // 已獲取連接 if (conn != null) { // 檢測連接是否可用 if (conn.isValid()) { // 對(duì)之前的操作回滾 if (!conn.getRealConnection().getAutoCommit()) { conn.getRealConnection().rollback(); } conn.setConnectionTypeCode(assembleConnectionTypeCode(dataSource.getUrl(), username, password)); conn.setCheckoutTimestamp(System.currentTimeMillis()); conn.setLastUsedTimestamp(System.currentTimeMillis()); // 激活連接池?cái)?shù)+1 state.activeConnections.add(conn); state.requestCount++; state.accumulatedRequestTime += System.currentTimeMillis() - t; } else { // 連接壞掉了,超過一定閾值則拋異常提醒 state.badConnectionCount++; localBadConnectionCount++; conn = null; if (localBadConnectionCount > (poolMaximumIdleConnections + poolMaximumLocalBadConnectionTolerance)) { // 省略日志 throw new SQLException( "PooledDataSource: Could not get a good connection to the database."); } } } } } if (conn == null) { // 省略日志 throw new SQLException( "PooledDataSource: Unknown severe error condition. The connection pool returned a null connection."); } return conn; } }
上面的代碼都已經(jīng)加了注釋,總體流程不算復(fù)雜:
while => 連接為空
能否直接從池里拿連接 => 可以則獲取連接并返回
不能,查看池里的連接是否沒滿 => 沒滿則創(chuàng)建一個(gè)連接并返回
滿了,查看池里最早的連接是否超時(shí) => 超時(shí)則強(qiáng)制該連接回滾,然后獲取該連接并返回
未超時(shí),等待連接可用
檢測連接是否可用
釋放連接操作,更為簡單,判斷更少
protected void pushConnection(PooledConnection conn) throws SQLException { // 同步操作 synchronized (state) { // 從活動(dòng)池中移除連接 state.activeConnections.remove(conn); if (conn.isValid()) { // 不超過空閑連接數(shù) 并且連接是同一類型的連接 if (state.idleConnections.size() < poolMaximumIdleConnections && conn.getConnectionTypeCode() == expectedConnectionTypeCode) { state.accumulatedCheckoutTime += conn.getCheckoutTime(); if (!conn.getRealConnection().getAutoCommit()) { conn.getRealConnection().rollback(); } // 廢棄原先的對(duì)象 PooledConnection newConn = new PooledConnection(conn.getRealConnection(), this); state.idleConnections.add(newConn); newConn.setCreatedTimestamp(conn.getCreatedTimestamp()); newConn.setLastUsedTimestamp(conn.getLastUsedTimestamp()); // 該對(duì)象已經(jīng)不能用于連接了 conn.invalidate(); if (log.isDebugEnabled()) { log.debug("Returned connection " + newConn.getRealHashCode() + " to pool."); } state.notifyAll(); } else { state.accumulatedCheckoutTime += conn.getCheckoutTime(); if (!conn.getRealConnection().getAutoCommit()) { conn.getRealConnection().rollback(); } // 關(guān)閉連接 conn.getRealConnection().close(); if (log.isDebugEnabled()) { log.debug("Closed connection " + conn.getRealHashCode() + "."); } conn.invalidate(); } } else { if (log.isDebugEnabled()) { log.debug("A bad connection (" + conn.getRealHashCode() + ") attempted to return to the pool, discarding connection."); } state.badConnectionCount++; } } }
部分碼注釋已添加,這里就說一下總體流程:
從活動(dòng)池中移除連接
如果該連接可用
連接池未滿,則連接放回池中
滿了,回滾,關(guān)閉連接
總體流程大概就是這樣
以下還有兩個(gè)方法代碼較多,但邏輯都很簡單,稍微說明一下:
pingConnection() 執(zhí)行一條 SQL 檢測連接是否可用。
forceCloseAll() 回滾并關(guān)閉激活連接池和空閑連接池中的連接
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/72851.html
摘要:從使用到原理學(xué)習(xí)線程池關(guān)于線程池的使用,及原理分析分析角度新穎面向切面編程的基本用法基于注解的實(shí)現(xiàn)在軟件開發(fā)中,分散于應(yīng)用中多出的功能被稱為橫切關(guān)注點(diǎn)如事務(wù)安全緩存等。 Java 程序媛手把手教你設(shè)計(jì)模式中的撩妹神技 -- 上篇 遇一人白首,擇一城終老,是多么美好的人生境界,她和他歷經(jīng)風(fēng)雨慢慢變老,回首走過的點(diǎn)點(diǎn)滴滴,依然清楚的記得當(dāng)初愛情萌芽的模樣…… Java 進(jìn)階面試問題列表 -...
摘要:源碼閱讀之的配置文件所有配置會(huì)被類讀取,我們可以通過此類來了解各個(gè)配置是如何運(yùn)作的。是用于項(xiàng)目中存在多種數(shù)據(jù)庫時(shí)區(qū)分同一條對(duì)應(yīng)的數(shù)據(jù)庫。可以這樣認(rèn)為,在中的和組合才是一條的唯一標(biāo)識(shí)。如果發(fā)現(xiàn)自己的沒被正確識(shí)別,可以查看方法是否和預(yù)期一致。 MyBatis 源碼閱讀之 databaseId MyBatis 的配置文件所有配置會(huì)被 org.apache.ibatis.builder.xml...
摘要:簡介我從七月份開始閱讀源碼,并在隨后的天內(nèi)陸續(xù)更新了篇文章。考慮到超長文章對(duì)讀者不太友好,以及拆分文章工作量也不小等問題。經(jīng)過兩周緊張的排版,一本小小的源碼分析書誕生了。我在寫系列文章中,買了一本書作為參考,這本書是技術(shù)內(nèi)幕。 1.簡介 我從七月份開始閱讀MyBatis源碼,并在隨后的40天內(nèi)陸續(xù)更新了7篇文章。起初,我只是打算通過博客的形式進(jìn)行分享。但在寫作的過程中,發(fā)現(xiàn)要分析的代碼...
摘要:探索專為而設(shè)計(jì)的將探討進(jìn)行了何種改進(jìn),以及這些改進(jìn)背后的原因。關(guān)于最友好的文章進(jìn)階前言之前就寫過一篇關(guān)于最友好的文章反響很不錯(cuò),由于那篇文章的定位就是簡單友好,因此盡可能的摒棄復(fù)雜的概念,只抓住關(guān)鍵的東西來講,以保證大家都能看懂。 周月切換日歷 一個(gè)可以進(jìn)行周月切換的日歷,左右滑動(dòng)的切換月份,上下滑動(dòng)可以進(jìn)行周,月不同的視圖切換,可以進(jìn)行事件的標(biāo)記,以及節(jié)假日的顯示,功能豐富 Andr...
閱讀 2148·2021-10-12 10:11
閱讀 849·2021-10-09 09:41
閱讀 3768·2021-09-09 11:37
閱讀 1942·2021-09-08 10:41
閱讀 2644·2019-08-30 12:58
閱讀 2375·2019-08-30 10:58
閱讀 1281·2019-08-26 13:40
閱讀 4118·2019-08-26 13:36